🔒 fix: Return Approved Error Text From the Remaining Structured Tools (#17934)

This commit is contained in:
Marco Beretta 2026-11-09 15:35:18 +01:00 • committed by GitHub
parent 9bd44801ec
commit c6c79e4ea7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 703 additions and 111 deletions

View file

@ -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'));
}
}
}

View file

@ -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 {

View file

@ -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()),
});
}
}

View file

@ -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);

View file

@ -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()),
});
}
}

View file

@ -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;
},
});
}
}

View 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();
});
});

View file

@ -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,
}));

View file

@ -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(

View 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.');
});
});

View file

@ -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);
});
});

View file

@ -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(

View file

@ -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}`);
});
});
});

View file

@ -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.',