mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 17:56:50 +00:00
🔒 fix: Return Approved Error Text From the Remaining Structured Tools (#17934)
This commit is contained in:
parent
9bd44801ec
commit
c6c79e4ea7
14 changed files with 703 additions and 111 deletions
|
|
@ -6,6 +6,7 @@ const { Tool } = require('@librechat/agents/langchain/tools');
|
|||
const {
|
||||
applyAxiosProxyConfig,
|
||||
createMinimalRetentionRequest,
|
||||
formatToolError,
|
||||
getHttpsProxyAgent,
|
||||
resolveFluxResultRequest,
|
||||
} = require('@librechat/api');
|
||||
|
|
@ -255,13 +256,7 @@ class FluxAPI extends Tool {
|
|||
...this.getAxiosConfig(),
|
||||
});
|
||||
} catch (error) {
|
||||
const details = this.getDetails(error?.response?.data || error.message);
|
||||
logger.error('[FluxAPI] Error while submitting task:', details);
|
||||
|
||||
return this.returnValue(
|
||||
`Something went wrong when trying to generate the image. The Flux API may be unavailable:
|
||||
Error Message: ${details}`,
|
||||
);
|
||||
return this.returnValue(formatToolError(error, 'Flux'));
|
||||
}
|
||||
|
||||
const resultRequest = resolveFluxResultRequest(
|
||||
|
|
@ -339,8 +334,7 @@ class FluxAPI extends Tool {
|
|||
];
|
||||
return [response, { content }];
|
||||
} catch (error) {
|
||||
logger.error('Error processing image for agent:', error);
|
||||
return this.returnValue(`Failed to process the image. ${error.message}`);
|
||||
return this.returnValue(formatToolError(error, 'Flux'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -371,9 +365,7 @@ class FluxAPI extends Tool {
|
|||
this.result = this.returnMetadata ? result : this.wrapInMarkdown(result.filepath);
|
||||
return this.returnValue(this.result);
|
||||
} catch (error) {
|
||||
const details = this.getDetails(error?.message ?? 'No additional error details.');
|
||||
logger.error('Error while saving the image:', details);
|
||||
return this.returnValue(`Failed to save the image locally. ${details}`);
|
||||
return this.returnValue(formatToolError(error, 'Flux'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -423,9 +415,7 @@ class FluxAPI extends Tool {
|
|||
}
|
||||
return JSON.stringify(finetuneDetails);
|
||||
} catch (error) {
|
||||
const details = this.getDetails(error?.response?.data || error.message);
|
||||
logger.error('[FluxAPI] Error while getting finetunes:', details);
|
||||
const errorMsg = `Failed to get finetunes: ${details}`;
|
||||
const errorMsg = formatToolError(error, 'Flux');
|
||||
return this.isAgent ? this.returnValue([errorMsg, {}]) : new Error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
|
@ -493,12 +483,7 @@ class FluxAPI extends Tool {
|
|||
...this.getAxiosConfig(),
|
||||
});
|
||||
} catch (error) {
|
||||
const details = this.getDetails(error?.response?.data || error.message);
|
||||
logger.error('[FluxAPI] Error while submitting finetuned task:', details);
|
||||
return this.returnValue(
|
||||
`Something went wrong when trying to generate the finetuned image. The Flux API may be unavailable:
|
||||
Error Message: ${details}`,
|
||||
);
|
||||
return this.returnValue(formatToolError(error, 'Flux'));
|
||||
}
|
||||
|
||||
const resultRequest = resolveFluxResultRequest(
|
||||
|
|
@ -574,8 +559,7 @@ class FluxAPI extends Tool {
|
|||
];
|
||||
return [response, { content }];
|
||||
} catch (error) {
|
||||
logger.error('[FluxAPI] Error processing finetuned image for agent:', error);
|
||||
return this.returnValue(`Failed to process the finetuned image. ${error.message}`);
|
||||
return this.returnValue(formatToolError(error, 'Flux'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -597,9 +581,7 @@ class FluxAPI extends Tool {
|
|||
this.result = this.returnMetadata ? result : this.wrapInMarkdown(result.filepath);
|
||||
return this.returnValue(this.result);
|
||||
} catch (error) {
|
||||
const details = this.getDetails(error?.message ?? 'No additional error details.');
|
||||
logger.error('Error while saving the finetuned image:', details);
|
||||
return this.returnValue(`Failed to save the finetuned image locally. ${details}`);
|
||||
return this.returnValue(formatToolError(error, 'Flux'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
|||
const {
|
||||
geminiToolkit,
|
||||
loadServiceKey,
|
||||
ToolInputError,
|
||||
formatToolError,
|
||||
getBalanceConfig,
|
||||
getEnvProxyDispatcher,
|
||||
getTransactionsConfig,
|
||||
|
|
@ -110,9 +112,8 @@ async function initializeGeminiClient(options = {}) {
|
|||
const serviceKey = await loadServiceKey(credentialsPath);
|
||||
|
||||
if (!serviceKey || !serviceKey.project_id) {
|
||||
throw new Error(
|
||||
'Gemini Image Generation requires one of: user-provided API key, GEMINI_API_KEY or GOOGLE_KEY env var, or a valid Google service account. ' +
|
||||
`Service account file not found or invalid at: ${credentialsPath}`,
|
||||
throw new ToolInputError(
|
||||
'Gemini Image Generation requires one of: user-provided API key, GEMINI_API_KEY or GOOGLE_KEY env var, or a valid Google service account.',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -335,9 +336,8 @@ function createGeminiImageTool(fields = {}) {
|
|||
GOOGLE_KEY,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[GeminiImageGen] Failed to initialize client:', error);
|
||||
return [
|
||||
[{ type: ContentTypes.TEXT, text: `Failed to initialize Gemini: ${error.message}` }],
|
||||
[{ type: ContentTypes.TEXT, text: formatToolError(error, 'Gemini image generation') }],
|
||||
{ content: [], file_ids: [] },
|
||||
];
|
||||
}
|
||||
|
|
@ -389,9 +389,8 @@ function createGeminiImageTool(fields = {}) {
|
|||
config,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[GeminiImageGen] API error:', error);
|
||||
return [
|
||||
[{ type: ContentTypes.TEXT, text: `Image generation failed: ${error.message}` }],
|
||||
[{ type: ContentTypes.TEXT, text: formatToolError(error, 'Gemini image generation') }],
|
||||
{ content: [], file_ids: [] },
|
||||
];
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const { toolRequestBudget } = require('@librechat/api');
|
||||
const { Tool } = require('@librechat/agents/langchain/tools');
|
||||
const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
|
||||
const { runToolRequest, toolRequestBudget } = require('@librechat/api');
|
||||
|
||||
const googleSearchJsonSchema = {
|
||||
type: 'object',
|
||||
|
|
@ -57,25 +57,23 @@ class GoogleSearchResults extends Tool {
|
|||
async _call(input) {
|
||||
const { query, max_results = 5 } = input;
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/customsearch/v1?key=${this.apiKey}&cx=${
|
||||
this.searchEngineId
|
||||
}&q=${encodeURIComponent(query)}&num=${max_results}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: toolRequestBudget(this.timeouts).signal,
|
||||
},
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}: ${json.error.message}`);
|
||||
}
|
||||
|
||||
return JSON.stringify(json);
|
||||
return runToolRequest({
|
||||
service: 'Google Search',
|
||||
request: () =>
|
||||
fetch(
|
||||
`https://www.googleapis.com/customsearch/v1?key=${this.apiKey}&cx=${
|
||||
this.searchEngineId
|
||||
}&q=${encodeURIComponent(query)}&num=${max_results}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: toolRequestBudget(this.timeouts).signal,
|
||||
},
|
||||
),
|
||||
read: async (response) => JSON.stringify(await response.json()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const { tool } = require('@librechat/agents/langchain/tools');
|
||||
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
||||
const {
|
||||
logAxiosError,
|
||||
formatToolError,
|
||||
oaiToolkit,
|
||||
extractBaseURL,
|
||||
getProxyDispatcher,
|
||||
|
|
@ -180,10 +180,7 @@ function createOpenAIImageTools(fields = {}) {
|
|||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const message = '[image_gen_oai] Problem generating the image:';
|
||||
logAxiosError({ error, message });
|
||||
return returnValue(`Something went wrong when trying to generate the image. The OpenAI API may be unavailable:
|
||||
Error Message: ${error.message}`);
|
||||
return returnValue(formatToolError(error, 'OpenAI image generation'));
|
||||
} finally {
|
||||
if (abortHandler && derivedSignal) {
|
||||
derivedSignal.removeEventListener('abort', abortHandler);
|
||||
|
|
@ -396,10 +393,7 @@ Error Message: ${error.message}`);
|
|||
];
|
||||
return [textResponse, { content, file_ids }];
|
||||
} catch (error) {
|
||||
const message = '[image_edit_oai] Problem editing the image:';
|
||||
logAxiosError({ error, message });
|
||||
return returnValue(`Something went wrong when trying to edit the image. The OpenAI API may be unavailable:
|
||||
Error Message: ${error.message || 'Unknown error'}`);
|
||||
return returnValue(formatToolError(error, 'OpenAI image editing'));
|
||||
} finally {
|
||||
if (abortHandler && derivedSignal) {
|
||||
derivedSignal.removeEventListener('abort', abortHandler);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const { fetch } = require('undici');
|
||||
const { Tool } = require('@librechat/agents/langchain/tools');
|
||||
const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
|
||||
const { getEnvProxyDispatcher, toolRequestBudget } = require('@librechat/api');
|
||||
const { getEnvProxyDispatcher, runToolRequest, toolRequestBudget } = require('@librechat/api');
|
||||
|
||||
const tavilySearchJsonSchema = {
|
||||
type: 'object',
|
||||
|
|
@ -129,16 +129,11 @@ class TavilySearchResults extends Tool {
|
|||
fetchOptions.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.tavily.com/search', fetchOptions);
|
||||
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Request failed with status ${response.status}: ${json?.detail?.error || json?.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.stringify(json);
|
||||
return runToolRequest({
|
||||
service: 'Tavily',
|
||||
request: () => fetch('https://api.tavily.com/search', fetchOptions),
|
||||
read: async (response) => JSON.stringify(await response.json()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { toolRequestBudget } = require('@librechat/api');
|
||||
const { Tool } = require('@librechat/agents/langchain/tools');
|
||||
const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
|
||||
const { ToolInputError, runToolRequest, toolRequestBudget } = require('@librechat/api');
|
||||
|
||||
const traversaalSearchJsonSchema = {
|
||||
type: 'object',
|
||||
|
|
@ -53,47 +52,43 @@ class TraversaalSearch extends Tool {
|
|||
const body = {
|
||||
query: [query],
|
||||
};
|
||||
try {
|
||||
const response = await fetch('https://api-ares.traversaal.ai/live/predict', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
},
|
||||
body: JSON.stringify({ ...body }),
|
||||
signal: toolRequestBudget(this.timeouts).signal,
|
||||
});
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Request failed with status code ${response.status}: ${json.error ?? json.message}`,
|
||||
);
|
||||
}
|
||||
if (!json.data) {
|
||||
throw new Error('Could not parse Traversaal API results. Please try again.');
|
||||
}
|
||||
return runToolRequest({
|
||||
service: 'Traversaal',
|
||||
request: () =>
|
||||
fetch('https://api-ares.traversaal.ai/live/predict', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
},
|
||||
body: JSON.stringify({ ...body }),
|
||||
signal: toolRequestBudget(this.timeouts).signal,
|
||||
}),
|
||||
read: async (response) => {
|
||||
const json = await response.json();
|
||||
if (!json.data) {
|
||||
throw new ToolInputError('Could not parse Traversaal API results. Please try again.');
|
||||
}
|
||||
|
||||
const baseText = json.data?.response_text ?? '';
|
||||
const sources = json.data?.web_url;
|
||||
const noResponse = 'No response found in Traversaal API results';
|
||||
const baseText = json.data?.response_text ?? '';
|
||||
const sources = json.data?.web_url;
|
||||
const noResponse = 'No response found in Traversaal API results';
|
||||
|
||||
if (!baseText && !sources) {
|
||||
return noResponse;
|
||||
}
|
||||
if (!baseText && !sources) {
|
||||
return noResponse;
|
||||
}
|
||||
|
||||
const sourcesText = sources?.length ? '\n\nSources:\n - ' + sources.join('\n - ') : '';
|
||||
const sourcesText = sources?.length ? '\n\nSources:\n - ' + sources.join('\n - ') : '';
|
||||
|
||||
const result = baseText + sourcesText;
|
||||
const result = baseText + sourcesText;
|
||||
|
||||
if (!result) {
|
||||
return noResponse;
|
||||
}
|
||||
if (!result) {
|
||||
return noResponse;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Traversaal API request failed', error);
|
||||
return `Traversaal API request failed: ${error.message}`;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
109
api/app/clients/tools/structured/specs/GeminiImageGen.spec.js
Normal file
109
api/app/clients/tools/structured/specs/GeminiImageGen.spec.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
const { GoogleGenAI } = require('@google/genai');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const createGeminiImageTool = require('../GeminiImageGen');
|
||||
|
||||
jest.mock('@google/genai', () => ({ GoogleGenAI: jest.fn() }));
|
||||
jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn() }));
|
||||
jest.mock('~/models', () => ({
|
||||
spendTokens: jest.fn(),
|
||||
getFiles: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const SECRET = 'PRIVATE-PROVIDER-DETAIL';
|
||||
const FALLBACK = 'Error: The tool request failed.';
|
||||
const EMPTY_ARTIFACT = { content: [], file_ids: [] };
|
||||
|
||||
const providerError = (status) =>
|
||||
Object.assign(new Error(JSON.stringify({ error: { code: status, message: SECRET } })), {
|
||||
status,
|
||||
});
|
||||
|
||||
const failGeneration = (error) =>
|
||||
GoogleGenAI.mockImplementation(() => ({
|
||||
models: { generateContent: jest.fn().mockRejectedValue(error) },
|
||||
}));
|
||||
|
||||
const createTool = (fields = {}) =>
|
||||
createGeminiImageTool({
|
||||
isAgent: true,
|
||||
req: { user: { id: 'user-1' } },
|
||||
userId: 'user-1',
|
||||
GEMINI_API_KEY: 'gemini-key',
|
||||
...fields,
|
||||
});
|
||||
|
||||
const run = async (tool) => {
|
||||
const [content, artifact] = await tool.func({ prompt: 'a cat' });
|
||||
return { text: content.map((part) => part.text).join('\n'), artifact };
|
||||
};
|
||||
|
||||
/** True when any logged argument, including an error message, carries the text. */
|
||||
const logged = (text) =>
|
||||
[logger.warn, logger.error, logger.info, logger.debug]
|
||||
.flatMap((method) => method.mock.calls.flat())
|
||||
.some((arg) => `${arg instanceof Error ? arg.message : JSON.stringify(arg)}`.includes(text));
|
||||
|
||||
describe('GeminiImageGen approved error text', () => {
|
||||
let originalEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
jest.restoreAllMocks();
|
||||
for (const method of ['warn', 'error', 'info', 'debug']) {
|
||||
jest.spyOn(logger, method).mockImplementation(() => logger);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('words a failing generation from its status, never the provider message', async () => {
|
||||
failGeneration(providerError(403));
|
||||
|
||||
const { text, artifact } = await run(createTool());
|
||||
|
||||
expect(text).toBe(
|
||||
'Error: Gemini image generation rejected the configured credentials (status 403).',
|
||||
);
|
||||
expect(artifact).toEqual(EMPTY_ARTIFACT);
|
||||
expect(logger.warn).toHaveBeenCalledWith('[Gemini image generation] Tool request failed', {
|
||||
status: 403,
|
||||
});
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the stable fallback for a generation failure without a status', async () => {
|
||||
failGeneration(new Error(`socket hang up ${SECRET}`));
|
||||
|
||||
const { text, artifact } = await run(createTool());
|
||||
|
||||
expect(text).toBe(FALLBACK);
|
||||
expect(artifact).toEqual(EMPTY_ARTIFACT);
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the stable fallback when the client cannot be created', async () => {
|
||||
GoogleGenAI.mockImplementation(() => {
|
||||
throw new Error(`invalid credentials ${SECRET}`);
|
||||
});
|
||||
|
||||
const { text, artifact } = await run(createTool());
|
||||
|
||||
expect(text).toBe(FALLBACK);
|
||||
expect(artifact).toEqual(EMPTY_ARTIFACT);
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps its configuration guidance without naming the service account path', async () => {
|
||||
process.env.GOOGLE_SERVICE_KEY_FILE = `/missing/${SECRET}/auth.json`;
|
||||
|
||||
const { text } = await run(createTool({ GEMINI_API_KEY: undefined, GOOGLE_KEY: undefined }));
|
||||
|
||||
expect(text).toBe(
|
||||
'Error: Gemini Image Generation requires one of: user-provided API key, GEMINI_API_KEY or GOOGLE_KEY env var, or a valid Google service account.',
|
||||
);
|
||||
expect(text).not.toContain('/missing/');
|
||||
expect(GoogleGenAI).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ const { getEnvProxyDispatcher } = require('@librechat/api');
|
|||
jest.mock('undici');
|
||||
jest.mock('@librechat/api', () => ({
|
||||
getEnvProxyDispatcher: jest.fn(),
|
||||
runToolRequest: jest.requireActual('@librechat/api').runToolRequest,
|
||||
toolRequestBudget: jest.requireActual('@librechat/api').toolRequestBudget,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
const axios = require('axios');
|
||||
const fetch = require('node-fetch');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
const { ToolMessage } = require('@librechat/agents/langchain/messages');
|
||||
const StableDiffusionAPI = require('../StableDiffusion');
|
||||
|
|
@ -307,10 +308,112 @@ describe('image tools - agent mode ToolMessage format', () => {
|
|||
expect(result).toBeInstanceOf(ToolMessage);
|
||||
const contentStr =
|
||||
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
||||
expect(contentStr).toContain('Something went wrong');
|
||||
expect(contentStr).toBe('Error: The tool request failed.');
|
||||
expect(contentStr).not.toContain('Network error');
|
||||
expect(result.artifact).toBeDefined();
|
||||
});
|
||||
|
||||
describe('approved error text', () => {
|
||||
const SECRET = 'PRIVATE-PROVIDER-DETAIL';
|
||||
const FALLBACK = 'Error: The tool request failed.';
|
||||
|
||||
const providerError = (status) =>
|
||||
Object.assign(new Error(`Request failed with status code ${status}`), {
|
||||
isAxiosError: true,
|
||||
response: { status, data: { detail: SECRET } },
|
||||
});
|
||||
|
||||
const contentOf = async (flux, args) => {
|
||||
const invokePromise = flux.invoke(makeToolCall('flux', args));
|
||||
await jest.runAllTimersAsync();
|
||||
const result = await invokePromise;
|
||||
return typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
||||
};
|
||||
|
||||
/** True when any logged argument, including an error message, carries the text. */
|
||||
const logged = (text) =>
|
||||
[logger.warn, logger.error, logger.info, logger.debug]
|
||||
.flatMap((method) => method.mock.calls.flat())
|
||||
.some((arg) =>
|
||||
`${arg instanceof Error ? arg.message : JSON.stringify(arg)}`.includes(text),
|
||||
);
|
||||
|
||||
const generate = { prompt: 'a box', endpoint: '/v1/flux-dev' };
|
||||
const finetuned = { action: 'generate_finetuned', prompt: 'a box', finetune_id: 'ft-1' };
|
||||
|
||||
it('words a rejected generation request from its status, never the provider body', async () => {
|
||||
axios.post.mockRejectedValue(providerError(401));
|
||||
|
||||
const content = await contentOf(new FluxAPI({ isAgent: true }), generate);
|
||||
|
||||
expect(content).toBe('Error: Flux rejected the configured credentials (status 401).');
|
||||
expect(logger.warn).toHaveBeenCalledWith('[Flux] Tool request failed', { status: 401 });
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('words a rejected finetuned generation request from its status', async () => {
|
||||
axios.post.mockRejectedValue(providerError(429));
|
||||
|
||||
const content = await contentOf(new FluxAPI({ isAgent: true }), finetuned);
|
||||
|
||||
expect(content).toBe(
|
||||
'Error: Flux is rate limiting requests; try again later (status 429).',
|
||||
);
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['generation', generate],
|
||||
['finetuned generation', finetuned],
|
||||
])('returns the fallback when the %s image cannot be downloaded', async (_label, args) => {
|
||||
fetch.mockRejectedValue(
|
||||
new Error(`request to https://cdn.example.com/?sig=${SECRET} failed`),
|
||||
);
|
||||
|
||||
const content = await contentOf(new FluxAPI({ isAgent: true }), args);
|
||||
|
||||
expect(content).toBe(FALLBACK);
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['generation', generate],
|
||||
['finetuned generation', finetuned],
|
||||
])('returns the fallback when the %s image cannot be saved', async (_label, args) => {
|
||||
const processFileURL = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error(`EACCES: permission denied, open '/srv/${SECRET}.png'`));
|
||||
const flux = new FluxAPI({ isAgent: false, processFileURL, userId: 'user-1' });
|
||||
|
||||
const content = await contentOf(flux, args);
|
||||
|
||||
expect(content).toBe(FALLBACK);
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('words a failing finetune listing from its status for an agent', async () => {
|
||||
axios.get.mockRejectedValue(providerError(503));
|
||||
|
||||
const content = await contentOf(new FluxAPI({ isAgent: true }), {
|
||||
action: 'list_finetunes',
|
||||
});
|
||||
|
||||
expect(content).toBe('Error: Flux is unavailable; try again later (status 503).');
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the approved text in the error a non-agent finetune listing returns', async () => {
|
||||
axios.get.mockRejectedValue(providerError(503));
|
||||
const flux = new FluxAPI({ isAgent: false, processFileURL: jest.fn() });
|
||||
|
||||
const result = await flux.getMyFinetunes('test-flux-key');
|
||||
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(result.message).toBe('Error: Flux is unavailable; try again later (status 503).');
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('routes a finetuned endpoint through generateFinetunedImage even when action is left as "generate"', async () => {
|
||||
const flux = new FluxAPI({ isAgent: true });
|
||||
const invokePromise = flux.invoke(
|
||||
|
|
|
|||
139
api/app/clients/tools/structured/specs/searchErrors.spec.js
Normal file
139
api/app/clients/tools/structured/specs/searchErrors.spec.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
const { fetch: undiciFetch } = require('undici');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const TavilySearchResults = require('../TavilySearchResults');
|
||||
const TraversaalSearch = require('../TraversaalSearch');
|
||||
const GoogleSearch = require('../GoogleSearch');
|
||||
|
||||
jest.mock('undici', () => ({ ...jest.requireActual('undici'), fetch: jest.fn() }));
|
||||
|
||||
const SECRET = 'PRIVATE-PROVIDER-DETAIL';
|
||||
const FALLBACK = 'Error: The tool request failed.';
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const tools = [
|
||||
{
|
||||
service: 'Google Search',
|
||||
create: () =>
|
||||
new GoogleSearch({ GOOGLE_SEARCH_API_KEY: 'google-key', GOOGLE_CSE_ID: 'search-engine' }),
|
||||
transport: () => global.fetch,
|
||||
errorBody: { error: { message: SECRET } },
|
||||
success: { body: { items: [{ title: 'Paris' }] }, expected: '{"items":[{"title":"Paris"}]}' },
|
||||
},
|
||||
{
|
||||
service: 'Traversaal',
|
||||
create: () => new TraversaalSearch({ TRAVERSAAL_API_KEY: 'traversaal-key' }),
|
||||
transport: () => global.fetch,
|
||||
errorBody: { error: SECRET, message: SECRET },
|
||||
success: {
|
||||
body: { data: { response_text: 'Paris is sunny', web_url: ['https://example.com/paris'] } },
|
||||
expected: 'Paris is sunny\n\nSources:\n - https://example.com/paris',
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'Tavily',
|
||||
create: () => new TavilySearchResults({ TAVILY_API_KEY: 'tavily-key' }),
|
||||
transport: () => undiciFetch,
|
||||
errorBody: { detail: { error: SECRET }, error: SECRET },
|
||||
success: { body: { results: [] }, expected: '{"results":[]}' },
|
||||
},
|
||||
];
|
||||
|
||||
/** True when any logged argument, including an error message, carries the text. */
|
||||
const logged = (text) =>
|
||||
[logger.warn, logger.error, logger.info, logger.debug]
|
||||
.flatMap((method) => method.mock.calls.flat())
|
||||
.some((arg) => `${arg instanceof Error ? arg.message : JSON.stringify(arg)}`.includes(text));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
undiciFetch.mockReset();
|
||||
global.fetch = jest.fn();
|
||||
for (const method of ['warn', 'error', 'info', 'debug']) {
|
||||
jest.spyOn(logger, method).mockImplementation(() => logger);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe.each(tools)(
|
||||
'$service approved error text',
|
||||
({ service, create, transport, errorBody, success }) => {
|
||||
const call = () => create()._call({ query: 'weather in Paris' });
|
||||
|
||||
it('words a failing HTTP status from the status alone, never the provider body', async () => {
|
||||
transport().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: jest.fn().mockResolvedValue(errorBody),
|
||||
});
|
||||
|
||||
const result = await call();
|
||||
|
||||
expect(result).toBe(`Error: ${service} rejected the configured credentials (status 403).`);
|
||||
expect(result).not.toContain(SECRET);
|
||||
expect(logger.warn).toHaveBeenCalledWith(`[${service}] Tool request failed`, {
|
||||
status: 403,
|
||||
});
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a body that is not JSON', () => jest.fn().mockRejectedValue(new SyntaxError(SECRET))],
|
||||
['a body without an error field', () => jest.fn().mockResolvedValue({})],
|
||||
])('approves the status of a failing response with %s', async (_label, makeJson) => {
|
||||
transport().mockResolvedValue({ ok: false, status: 502, json: makeJson() });
|
||||
|
||||
const result = await call();
|
||||
|
||||
expect(result).toBe(`Error: ${service} is unavailable; try again later (status 502).`);
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels the unread body of a failing response so its connection is released', async () => {
|
||||
const cancel = jest.fn().mockResolvedValue(undefined);
|
||||
const json = jest.fn();
|
||||
transport().mockResolvedValue({ ok: false, status: 500, body: { cancel }, json });
|
||||
|
||||
await call();
|
||||
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the stable fallback for a transport failure and logs only its type', async () => {
|
||||
transport().mockRejectedValue(new Error(`connect ECONNREFUSED 10.0.0.5:443 ${SECRET}`));
|
||||
|
||||
const result = await call();
|
||||
|
||||
expect(result).toBe(FALLBACK);
|
||||
expect(logger.warn).toHaveBeenCalledWith(`[${service}] Tool request failed`, {
|
||||
type: 'Error',
|
||||
});
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps returning the provider results on success', async () => {
|
||||
transport().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: jest.fn().mockResolvedValue(success.body),
|
||||
});
|
||||
|
||||
await expect(call()).resolves.toBe(success.expected);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe('Traversaal approved error text', () => {
|
||||
it('keeps its own guidance when the answer cannot be parsed', async () => {
|
||||
global.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
|
||||
const result = await new TraversaalSearch({ TRAVERSAAL_API_KEY: 'traversaal-key' })._call({
|
||||
query: 'weather in Paris',
|
||||
});
|
||||
|
||||
expect(result).toBe('Error: Could not parse Traversaal API results. Please try again.');
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
formatToolError: jest.requireActual('@librechat/api').formatToolError,
|
||||
logAxiosError: jest.fn(),
|
||||
oaiToolkit: {
|
||||
image_gen_oai: {
|
||||
|
|
@ -265,3 +266,106 @@ describe('OpenAIImageTools - IMAGE_GEN_OAI_MODEL environment variable', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAIImageTools - approved error text', () => {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const SECRET = 'PRIVATE-PROVIDER-DETAIL';
|
||||
const FALLBACK = 'Error: The tool request failed.';
|
||||
let originalEnv;
|
||||
|
||||
const providerError = (status) =>
|
||||
Object.assign(new Error(`${status} ${SECRET}`), {
|
||||
status,
|
||||
response: { status, data: { error: { message: SECRET } } },
|
||||
});
|
||||
|
||||
const createTools = () =>
|
||||
createOpenAIImageTools({
|
||||
isAgent: true,
|
||||
override: false,
|
||||
req: { user: { id: 'test-user' } },
|
||||
imageFiles: [
|
||||
{
|
||||
file_id: 'source-image',
|
||||
source: 'local',
|
||||
filepath: 'source-image.png',
|
||||
filename: 'source-image.png',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const failGeneration = (error) =>
|
||||
OpenAI.mockImplementation(() => ({
|
||||
images: { generate: jest.fn().mockRejectedValue(error) },
|
||||
}));
|
||||
|
||||
/** True when any logged argument, including an error message, carries the text. */
|
||||
const logged = (text) =>
|
||||
[logger.warn, logger.error, logger.debug, logAxiosError]
|
||||
.flatMap((method) => method.mock.calls.flat())
|
||||
.some((arg) => `${arg instanceof Error ? arg.message : JSON.stringify(arg)}`.includes(text));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
originalEnv = { ...process.env };
|
||||
process.env.IMAGE_GEN_OAI_API_KEY = 'test-api-key';
|
||||
delete process.env.IMAGE_GEN_OAI_BASEURL;
|
||||
delete process.env.IMAGE_GEN_OAI_AZURE_API_VERSION;
|
||||
getStrategyFunctions.mockReturnValue({
|
||||
getDownloadStream: jest.fn(async () => Readable.from([Buffer.from('image-data')])),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('words a failing generation from its status, never the provider message', async () => {
|
||||
failGeneration(providerError(400));
|
||||
const [imageGenTool] = createTools();
|
||||
|
||||
const [text] = await imageGenTool.func({ prompt: 'test prompt' });
|
||||
|
||||
expect(text).toBe(
|
||||
'Error: OpenAI image generation rejected the request parameters; check them and try again (status 400).',
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith('[OpenAI image generation] Tool request failed', {
|
||||
status: 400,
|
||||
});
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the stable fallback for a generation failure without a status', async () => {
|
||||
failGeneration(new Error(`connect ECONNREFUSED ${SECRET}`));
|
||||
const [imageGenTool] = createTools();
|
||||
|
||||
const [text] = await imageGenTool.func({ prompt: 'test prompt' });
|
||||
|
||||
expect(text).toBe(FALLBACK);
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('words a failing edit from its status, never the provider message', async () => {
|
||||
axios.post.mockRejectedValue(providerError(401));
|
||||
const [, imageEditTool] = createTools();
|
||||
|
||||
const [text] = await imageEditTool.func({ prompt: 'test prompt', image_ids: ['source-image'] });
|
||||
|
||||
expect(text).toBe(
|
||||
'Error: OpenAI image editing rejected the configured credentials (status 401).',
|
||||
);
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the stable fallback for an edit failure without a status', async () => {
|
||||
axios.post.mockRejectedValue(new Error(`socket hang up ${SECRET}`));
|
||||
const [, imageEditTool] = createTools();
|
||||
|
||||
const [text] = await imageEditTool.func({ prompt: 'test prompt', image_ids: ['source-image'] });
|
||||
|
||||
expect(text).toBe(FALLBACK);
|
||||
expect(logged(SECRET)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,6 +104,36 @@ test.describe('approved tool error text', () => {
|
|||
expect(JSON.stringify(out)).not.toContain('probe-app-id');
|
||||
});
|
||||
|
||||
test('a failing Google, Traversaal or Tavily search returns approved text, not the provider message @scenario:search-tools-approved-error-text', () => {
|
||||
test.setTimeout(300_000);
|
||||
const result = run(
|
||||
'npx',
|
||||
['jest', 'app/clients/tools/structured/specs/searchErrors.spec.js', '--maxWorkers=1'],
|
||||
{ cwd: apiRoot },
|
||||
);
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.output).toMatch(/(?<!\d)19 passed/);
|
||||
});
|
||||
|
||||
test('a failing Flux, Gemini or OpenAI image tool returns approved text, not the provider message @scenario:image-tools-approved-error-text', () => {
|
||||
test.setTimeout(300_000);
|
||||
const result = run(
|
||||
'npx',
|
||||
[
|
||||
'jest',
|
||||
'app/clients/tools/structured/specs/imageTools-agent.spec.js',
|
||||
'app/clients/tools/structured/specs/GeminiImageGen.spec.js',
|
||||
'test/app/clients/tools/structured/OpenAIImageTools.test.js',
|
||||
'-t',
|
||||
'approved error text',
|
||||
'--maxWorkers=1',
|
||||
],
|
||||
{ cwd: apiRoot },
|
||||
);
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.output).toMatch(/(?<!\d)16 passed/);
|
||||
});
|
||||
|
||||
test('an Assistants required action whose tool throws submits approved text @scenario:assistant-tool-error-approved-text', () => {
|
||||
test.setTimeout(300_000);
|
||||
const result = run(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
ToolInputError,
|
||||
getToolkitKey,
|
||||
checkPluginAuth,
|
||||
runToolRequest,
|
||||
formatToolError,
|
||||
getToolErrorText,
|
||||
filterUniquePlugins,
|
||||
|
|
@ -447,4 +448,96 @@ describe('format.ts helper functions', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runToolRequest', () => {
|
||||
const service = 'Search';
|
||||
const ok = { ok: true, status: 200 };
|
||||
|
||||
it('returns what read produces for a successful response', async () => {
|
||||
const read = jest.fn().mockResolvedValue('results');
|
||||
|
||||
await expect(runToolRequest({ service, request: async () => ok, read })).resolves.toBe(
|
||||
'results',
|
||||
);
|
||||
expect(read).toHaveBeenCalledWith(ok);
|
||||
});
|
||||
|
||||
it('words a failing status from the status alone and cancels the unread body', async () => {
|
||||
const cancel = jest.fn().mockResolvedValue(undefined);
|
||||
const read = jest.fn();
|
||||
|
||||
const text = await runToolRequest({
|
||||
service,
|
||||
request: async () => ({ ok: false, status: 429, body: { cancel } }),
|
||||
read,
|
||||
});
|
||||
|
||||
expect(text).toBe('Error: Search is rate limiting requests; try again later (status 429).');
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('words the status even when the body cannot be cancelled', async () => {
|
||||
const cancel = jest.fn().mockRejectedValue(new Error('PRIVATE-DETAIL'));
|
||||
|
||||
const text = await runToolRequest({
|
||||
service,
|
||||
request: async () => ({ ok: false, status: 503, body: { cancel } }),
|
||||
read: jest.fn(),
|
||||
});
|
||||
|
||||
expect(text).toBe('Error: Search is unavailable; try again later (status 503).');
|
||||
});
|
||||
|
||||
it('applies the wording a service documents for a status', async () => {
|
||||
const text = await runToolRequest({
|
||||
service,
|
||||
request: async () => ({ ok: false, status: 501, body: null }),
|
||||
read: jest.fn(),
|
||||
statusOverrides: { 501: 'Rephrase the query.' },
|
||||
});
|
||||
|
||||
expect(text).toBe('Error: Rephrase the query.');
|
||||
});
|
||||
|
||||
it('returns the fallback for a rejected request and logs only its type', async () => {
|
||||
const warn = jest.spyOn(logger, 'warn').mockImplementation(() => logger);
|
||||
|
||||
const text = await runToolRequest({
|
||||
service,
|
||||
request: async () => {
|
||||
throw new Error('connect ECONNREFUSED 10.0.0.5:443 PRIVATE-DETAIL');
|
||||
},
|
||||
read: jest.fn(),
|
||||
});
|
||||
|
||||
expect(text).toBe(`Error: ${TOOL_ERROR_FALLBACK}`);
|
||||
expect(warn).toHaveBeenCalledWith('[Search] Tool request failed', { type: 'Error' });
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain('PRIVATE-DETAIL');
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps the guidance of a tool input error thrown while reading, and hides other errors', async () => {
|
||||
const request = async () => ok;
|
||||
|
||||
await expect(
|
||||
runToolRequest({
|
||||
service,
|
||||
request,
|
||||
read: () => {
|
||||
throw new ToolInputError('Could not parse the answer. Please try again.');
|
||||
},
|
||||
}),
|
||||
).resolves.toBe('Error: Could not parse the answer. Please try again.');
|
||||
await expect(
|
||||
runToolRequest({
|
||||
service,
|
||||
request,
|
||||
read: () => {
|
||||
throw new SyntaxError('Unexpected token < PRIVATE-DETAIL');
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(`Error: ${TOOL_ERROR_FALLBACK}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -168,6 +168,56 @@ export function formatToolError(
|
|||
return `Error: ${TOOL_ERROR_FALLBACK}`;
|
||||
}
|
||||
|
||||
/** The part of a fetch response a tool request reads, shared by the global and undici responses. */
|
||||
export interface ToolHttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body?: { cancel?: () => Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
export interface ToolRequestParams<R extends ToolHttpResponse> {
|
||||
/** The upstream service as the model should read it, such as `Google Search`. */
|
||||
service: string;
|
||||
/** Sends the request; a rejection is a transport failure. */
|
||||
request: () => Promise<R>;
|
||||
/** Reads the text a successful response contributes to the tool result. */
|
||||
read: (response: R) => string | Promise<string>;
|
||||
/** Wording for statuses the service documents specifically. */
|
||||
statusOverrides?: Readonly<Partial<Record<number, string>>>;
|
||||
}
|
||||
|
||||
async function cancelResponseBody(response: ToolHttpResponse): Promise<void> {
|
||||
try {
|
||||
await response.body?.cancel?.();
|
||||
} catch {
|
||||
/** Only the status reaches the model, so a body that will not cancel changes nothing. */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a tool's upstream request and returns the text for the model: what `read` returns for a
|
||||
* successful response, otherwise the approved error text. A failing response is worded from its
|
||||
* status alone, so its body is cancelled unread to release the connection. `read` may throw a
|
||||
* `ToolInputError` to hand the model guidance the tool wrote itself.
|
||||
*/
|
||||
export async function runToolRequest<R extends ToolHttpResponse>({
|
||||
service,
|
||||
request,
|
||||
read,
|
||||
statusOverrides,
|
||||
}: ToolRequestParams<R>): Promise<string> {
|
||||
try {
|
||||
const response = await request();
|
||||
if (!response.ok) {
|
||||
await cancelResponseBody(response);
|
||||
return formatToolHttpError(service, response.status, statusOverrides);
|
||||
}
|
||||
return await read(response);
|
||||
} catch (error) {
|
||||
return formatToolError(error, service, statusOverrides);
|
||||
}
|
||||
}
|
||||
|
||||
/** The Wolfram|Alpha LLM API answers 501 when it cannot interpret the input; its body echoes the query. */
|
||||
export const WOLFRAM_STATUS_TEXT: Readonly<Partial<Record<number, string>>> = {
|
||||
501: 'Wolfram|Alpha could not interpret the input. Rephrase it as a simpler query, such as single keywords or standard math notation, and try again.',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue