diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js index e318adab86..bbade450e8 100644 --- a/api/app/clients/tools/structured/FluxAPI.js +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -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')); } } } diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js index 04265bbba9..6e45e6be80 100644 --- a/api/app/clients/tools/structured/GeminiImageGen.js +++ b/api/app/clients/tools/structured/GeminiImageGen.js @@ -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 { diff --git a/api/app/clients/tools/structured/GoogleSearch.js b/api/app/clients/tools/structured/GoogleSearch.js index b1e0c0b778..27a669189d 100644 --- a/api/app/clients/tools/structured/GoogleSearch.js +++ b/api/app/clients/tools/structured/GoogleSearch.js @@ -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()), + }); } } diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js index b3a0a84300..0553a47061 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -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); diff --git a/api/app/clients/tools/structured/TavilySearchResults.js b/api/app/clients/tools/structured/TavilySearchResults.js index 3e2850f657..454f64acf2 100644 --- a/api/app/clients/tools/structured/TavilySearchResults.js +++ b/api/app/clients/tools/structured/TavilySearchResults.js @@ -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()), + }); } } diff --git a/api/app/clients/tools/structured/TraversaalSearch.js b/api/app/clients/tools/structured/TraversaalSearch.js index 9dca64c728..4dc0d94e7f 100644 --- a/api/app/clients/tools/structured/TraversaalSearch.js +++ b/api/app/clients/tools/structured/TraversaalSearch.js @@ -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; + }, + }); } } diff --git a/api/app/clients/tools/structured/specs/GeminiImageGen.spec.js b/api/app/clients/tools/structured/specs/GeminiImageGen.spec.js new file mode 100644 index 0000000000..41a8d566f2 --- /dev/null +++ b/api/app/clients/tools/structured/specs/GeminiImageGen.spec.js @@ -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(); + }); +}); diff --git a/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js index 15f70bade9..fe0a8c17c4 100644 --- a/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js +++ b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js @@ -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, })); diff --git a/api/app/clients/tools/structured/specs/imageTools-agent.spec.js b/api/app/clients/tools/structured/specs/imageTools-agent.spec.js index 841a3f6a9a..f0fb6725bf 100644 --- a/api/app/clients/tools/structured/specs/imageTools-agent.spec.js +++ b/api/app/clients/tools/structured/specs/imageTools-agent.spec.js @@ -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( diff --git a/api/app/clients/tools/structured/specs/searchErrors.spec.js b/api/app/clients/tools/structured/specs/searchErrors.spec.js new file mode 100644 index 0000000000..e2a9ea3bef --- /dev/null +++ b/api/app/clients/tools/structured/specs/searchErrors.spec.js @@ -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.'); + }); +}); diff --git a/api/test/app/clients/tools/structured/OpenAIImageTools.test.js b/api/test/app/clients/tools/structured/OpenAIImageTools.test.js index 7a81865b88..1528bef30d 100644 --- a/api/test/app/clients/tools/structured/OpenAIImageTools.test.js +++ b/api/test/app/clients/tools/structured/OpenAIImageTools.test.js @@ -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); + }); +}); diff --git a/e2e/specs/mock/scenarios/tool-error-text.spec.ts b/e2e/specs/mock/scenarios/tool-error-text.spec.ts index cd208d8267..c6276fb83d 100644 --- a/e2e/specs/mock/scenarios/tool-error-text.spec.ts +++ b/e2e/specs/mock/scenarios/tool-error-text.spec.ts @@ -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(/(? { + 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(/(? { test.setTimeout(300_000); const result = run( diff --git a/packages/api/src/tools/format.spec.ts b/packages/api/src/tools/format.spec.ts index cdb3e3962b..702d327ea5 100644 --- a/packages/api/src/tools/format.spec.ts +++ b/packages/api/src/tools/format.spec.ts @@ -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}`); + }); + }); }); diff --git a/packages/api/src/tools/format.ts b/packages/api/src/tools/format.ts index 71f25ce387..f7539ad343 100644 --- a/packages/api/src/tools/format.ts +++ b/packages/api/src/tools/format.ts @@ -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 } | null; +} + +export interface ToolRequestParams { + /** 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; + /** Reads the text a successful response contributes to the tool result. */ + read: (response: R) => string | Promise; + /** Wording for statuses the service documents specifically. */ + statusOverrides?: Readonly>>; +} + +async function cancelResponseBody(response: ToolHttpResponse): Promise { + 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({ + service, + request, + read, + statusOverrides, +}: ToolRequestParams): Promise { + 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>> = { 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.',