diff --git a/.env.example b/.env.example index f2a51198f42..1e27f0b9710 100644 --- a/.env.example +++ b/.env.example @@ -256,6 +256,7 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= # DALLE3_AZURE_API_VERSION= # DALLE2_AZURE_API_VERSION= + # Google #----------------- GOOGLE_SEARCH_API_KEY= @@ -514,4 +515,9 @@ HELP_AND_FAQ_URL=https://librechat.ai # no-cache: Forces validation with server before using cached version # no-store: Prevents storing the response entirely -# must-revalidate: Prevents using stale content when offline \ No newline at end of file +# must-revalidate: Prevents using stale content when offline + +#=====================================================# +# OpenWeather # +#=====================================================# +OPENWEATHER_API_KEY= \ No newline at end of file diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js index a8532d4581f..176c26dd78d 100644 --- a/api/app/clients/tools/index.js +++ b/api/app/clients/tools/index.js @@ -8,6 +8,7 @@ const StructuredSD = require('./structured/StableDiffusion'); const GoogleSearchAPI = require('./structured/GoogleSearch'); const TraversaalSearch = require('./structured/TraversaalSearch'); const TavilySearchResults = require('./structured/TavilySearchResults'); +const OpenWeather = require('./structured/OpenWeather'); module.exports = { availableTools, @@ -19,4 +20,5 @@ module.exports = { TraversaalSearch, StructuredWolfram, TavilySearchResults, + OpenWeather, }; diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index d2748cdea11..4b5aabd1ab0 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -100,7 +100,6 @@ "pluginKey": "calculator", "description": "Perform simple and complex mathematical calculations.", "icon": "https://i.imgur.com/RHsSG5h.png", - "isAuthRequired": "false", "authConfig": [] }, { @@ -135,7 +134,20 @@ { "authField": "AZURE_AI_SEARCH_API_KEY", "label": "Azure AI Search API Key", - "description": "You need to provideq your API Key for Azure AI Search." + "description": "You need to provide your API Key for Azure AI Search." + } + ] + }, + { + "name": "OpenWeather", + "pluginKey": "open_weather", + "description": "Get weather forecasts and historical data from the OpenWeather API", + "icon": "/assets/openweather.png", + "authConfig": [ + { + "authField": "OPENWEATHER_API_KEY", + "label": "OpenWeather API Key", + "description": "Sign up at OpenWeather, then get your key at API keys." } ] } diff --git a/api/app/clients/tools/structured/OpenWeather.js b/api/app/clients/tools/structured/OpenWeather.js new file mode 100644 index 00000000000..b84225101c0 --- /dev/null +++ b/api/app/clients/tools/structured/OpenWeather.js @@ -0,0 +1,317 @@ +const { Tool } = require('@langchain/core/tools'); +const { z } = require('zod'); +const { getEnvironmentVariable } = require('@langchain/core/utils/env'); +const fetch = require('node-fetch'); + +/** + * Map user-friendly units to OpenWeather units. + * Defaults to Celsius if not specified. + */ +function mapUnitsToOpenWeather(unit) { + if (!unit) { + return 'metric'; + } // Default to Celsius + switch (unit) { + case 'Celsius': + return 'metric'; + case 'Kelvin': + return 'standard'; + case 'Fahrenheit': + return 'imperial'; + default: + return 'metric'; // fallback + } +} + +/** + * Recursively round temperature fields in the API response. + */ +function roundTemperatures(obj) { + const tempKeys = new Set([ + 'temp', + 'feels_like', + 'dew_point', + 'day', + 'min', + 'max', + 'night', + 'eve', + 'morn', + 'afternoon', + 'morning', + 'evening', + ]); + + if (Array.isArray(obj)) { + return obj.map((item) => roundTemperatures(item)); + } else if (obj && typeof obj === 'object') { + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (value && typeof value === 'object') { + obj[key] = roundTemperatures(value); + } else if (typeof value === 'number' && tempKeys.has(key)) { + obj[key] = Math.round(value); + } + } + } + return obj; +} + +class OpenWeather extends Tool { + name = 'open_weather'; + description = + 'Provides weather data from OpenWeather One Call API 3.0. ' + + 'Actions: help, current_forecast, timestamp, daily_aggregation, overview. ' + + 'If lat/lon not provided, specify "city" for geocoding. ' + + 'Units: "Celsius", "Kelvin", or "Fahrenheit" (default: Celsius). ' + + 'For timestamp action, use "date" in YYYY-MM-DD format.'; + + schema = z.object({ + action: z.enum(['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview']), + city: z.string().optional(), + lat: z.number().optional(), + lon: z.number().optional(), + exclude: z.string().optional(), + units: z.enum(['Celsius', 'Kelvin', 'Fahrenheit']).optional(), + lang: z.string().optional(), + date: z.string().optional(), // For timestamp and daily_aggregation + tz: z.string().optional(), + }); + + constructor(fields = {}) { + super(); + this.envVar = 'OPENWEATHER_API_KEY'; + this.override = fields.override ?? false; + this.apiKey = fields[this.envVar] ?? this.getApiKey(); + } + + getApiKey() { + const key = getEnvironmentVariable(this.envVar); + if (!key && !this.override) { + throw new Error(`Missing ${this.envVar} environment variable.`); + } + return key; + } + + async geocodeCity(city) { + const geocodeUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent( + city, + )}&limit=1&appid=${this.apiKey}`; + const res = await fetch(geocodeUrl); + const data = await res.json(); + if (!res.ok || !Array.isArray(data) || data.length === 0) { + throw new Error(`Could not find coordinates for city: ${city}`); + } + return { lat: data[0].lat, lon: data[0].lon }; + } + + convertDateToUnix(dateStr) { + const parts = dateStr.split('-'); + if (parts.length !== 3) { + throw new Error('Invalid date format. Expected YYYY-MM-DD.'); + } + const year = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10); + const day = parseInt(parts[2], 10); + if (isNaN(year) || isNaN(month) || isNaN(day)) { + throw new Error('Invalid date format. Expected YYYY-MM-DD with valid numbers.'); + } + + const dateObj = new Date(Date.UTC(year, month - 1, day, 0, 0, 0)); + if (isNaN(dateObj.getTime())) { + throw new Error('Invalid date provided. Cannot parse into a valid date.'); + } + + return Math.floor(dateObj.getTime() / 1000); + } + + async _call(args) { + try { + const { action, city, lat, lon, exclude, units, lang, date, tz } = args; + const owmUnits = mapUnitsToOpenWeather(units); + + if (action === 'help') { + return JSON.stringify( + { + title: 'OpenWeather One Call API 3.0 Help', + description: 'Guidance on using the OpenWeather One Call API 3.0.', + endpoints: { + current_and_forecast: { + endpoint: 'data/3.0/onecall', + data_provided: [ + 'Current weather', + 'Minute forecast (1h)', + 'Hourly forecast (48h)', + 'Daily forecast (8 days)', + 'Government weather alerts', + ], + required_params: [['lat', 'lon'], ['city']], + optional_params: ['exclude', 'units (Celsius/Kelvin/Fahrenheit)', 'lang'], + usage_example: { + city: 'Knoxville, Tennessee', + units: 'Fahrenheit', + lang: 'en', + }, + }, + weather_for_timestamp: { + endpoint: 'data/3.0/onecall/timemachine', + data_provided: [ + 'Historical weather (since 1979-01-01)', + 'Future forecast up to 4 days ahead', + ], + required_params: [ + ['lat', 'lon', 'date (YYYY-MM-DD)'], + ['city', 'date (YYYY-MM-DD)'], + ], + optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang'], + usage_example: { + city: 'Knoxville, Tennessee', + date: '2020-03-04', + units: 'Fahrenheit', + lang: 'en', + }, + }, + daily_aggregation: { + endpoint: 'data/3.0/onecall/day_summary', + data_provided: [ + 'Aggregated weather data for a specific date (1979-01-02 to 1.5 years ahead)', + ], + required_params: [ + ['lat', 'lon', 'date (YYYY-MM-DD)'], + ['city', 'date (YYYY-MM-DD)'], + ], + optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang', 'tz'], + usage_example: { + city: 'Knoxville, Tennessee', + date: '2020-03-04', + units: 'Celsius', + lang: 'en', + }, + }, + weather_overview: { + endpoint: 'data/3.0/onecall/overview', + data_provided: ['Human-readable weather summary (today/tomorrow)'], + required_params: [['lat', 'lon'], ['city']], + optional_params: ['date (YYYY-MM-DD)', 'units (Celsius/Kelvin/Fahrenheit)'], + usage_example: { + city: 'Knoxville, Tennessee', + date: '2024-05-13', + units: 'Celsius', + }, + }, + }, + notes: [ + 'If lat/lon not provided, you can specify a city name and it will be geocoded.', + 'For the timestamp action, provide a date in YYYY-MM-DD format instead of a Unix timestamp.', + 'By default, temperatures are returned in Celsius.', + 'You can specify units as Celsius, Kelvin, or Fahrenheit.', + 'All temperatures are rounded to the nearest degree.', + ], + errors: [ + '400: Bad Request (missing/invalid params)', + '401: Unauthorized (check API key)', + '404: Not Found (no data or city)', + '429: Too many requests', + '5xx: Internal error', + ], + }, + null, + 2, + ); + } + + let finalLat = lat; + let finalLon = lon; + + // If lat/lon not provided but city is given, geocode it + if ((finalLat == null || finalLon == null) && city) { + const coords = await this.geocodeCity(city); + finalLat = coords.lat; + finalLon = coords.lon; + } + + if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) { + if (typeof finalLat !== 'number' || typeof finalLon !== 'number') { + return 'Error: lat and lon are required and must be numbers for this action (or specify \'city\').'; + } + } + + const baseUrl = 'https://api.openweathermap.org/data/3.0'; + let endpoint = ''; + const params = new URLSearchParams({ appid: this.apiKey, units: owmUnits }); + + let dt; + if (action === 'timestamp') { + if (!date) { + return 'Error: For timestamp action, a \'date\' in YYYY-MM-DD format is required.'; + } + dt = this.convertDateToUnix(date); + } + + if (action === 'daily_aggregation' && !date) { + return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.'; + } + + switch (action) { + case 'current_forecast': + endpoint = '/onecall'; + params.append('lat', String(finalLat)); + params.append('lon', String(finalLon)); + if (exclude) { + params.append('exclude', exclude); + } + if (lang) { + params.append('lang', lang); + } + break; + case 'timestamp': + endpoint = '/onecall/timemachine'; + params.append('lat', String(finalLat)); + params.append('lon', String(finalLon)); + params.append('dt', String(dt)); + if (lang) { + params.append('lang', lang); + } + break; + case 'daily_aggregation': + endpoint = '/onecall/day_summary'; + params.append('lat', String(finalLat)); + params.append('lon', String(finalLon)); + params.append('date', date); + if (lang) { + params.append('lang', lang); + } + if (tz) { + params.append('tz', tz); + } + break; + case 'overview': + endpoint = '/onecall/overview'; + params.append('lat', String(finalLat)); + params.append('lon', String(finalLon)); + if (date) { + params.append('date', date); + } + break; + default: + return `Error: Unknown action: ${action}`; + } + + const url = `${baseUrl}${endpoint}?${params.toString()}`; + const response = await fetch(url); + const json = await response.json(); + if (!response.ok) { + return `Error: OpenWeather API request failed with status ${response.status}: ${ + json.message || JSON.stringify(json) + }`; + } + + const roundedJson = roundTemperatures(json); + return JSON.stringify(roundedJson); + } catch (err) { + return `Error: ${err.message}`; + } + } +} + +module.exports = OpenWeather; diff --git a/api/app/clients/tools/structured/specs/openWeather.integration.test.js b/api/app/clients/tools/structured/specs/openWeather.integration.test.js new file mode 100644 index 00000000000..07dd417cf1a --- /dev/null +++ b/api/app/clients/tools/structured/specs/openWeather.integration.test.js @@ -0,0 +1,224 @@ +// __tests__/openWeather.integration.test.js +const OpenWeather = require('../OpenWeather'); + +describe('OpenWeather Tool (Integration Test)', () => { + let tool; + + beforeAll(() => { + tool = new OpenWeather({ override: true }); + console.log('API Key present:', !!process.env.OPENWEATHER_API_KEY); + }); + + test('current_forecast with a real API key returns current weather', async () => { + // Check if API key is available + if (!process.env.OPENWEATHER_API_KEY) { + console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.'); + return; + } + + try { + const result = await tool.call({ + action: 'current_forecast', + city: 'London', + units: 'Celsius', + }); + + console.log('Raw API response:', result); + + const parsed = JSON.parse(result); + expect(parsed).toHaveProperty('current'); + expect(typeof parsed.current.temp).toBe('number'); + } catch (error) { + console.error('Test failed with error:', error); + throw error; + } + }); + + test('timestamp action with real API key returns historical data', async () => { + if (!process.env.OPENWEATHER_API_KEY) { + console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.'); + return; + } + + try { + // Use a date from yesterday to ensure data availability + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const dateStr = yesterday.toISOString().split('T')[0]; + + const result = await tool.call({ + action: 'timestamp', + city: 'London', + date: dateStr, + units: 'Celsius', + }); + + console.log('Timestamp API response:', result); + + const parsed = JSON.parse(result); + expect(parsed).toHaveProperty('data'); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data[0]).toHaveProperty('temp'); + } catch (error) { + console.error('Timestamp test failed with error:', error); + throw error; + } + }); + + test('daily_aggregation action with real API key returns aggregated data', async () => { + if (!process.env.OPENWEATHER_API_KEY) { + console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.'); + return; + } + + try { + // Use yesterday's date for aggregation + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const dateStr = yesterday.toISOString().split('T')[0]; + + const result = await tool.call({ + action: 'daily_aggregation', + city: 'London', + date: dateStr, + units: 'Celsius', + }); + + console.log('Daily aggregation API response:', result); + + const parsed = JSON.parse(result); + expect(parsed).toHaveProperty('temperature'); + expect(parsed.temperature).toHaveProperty('morning'); + expect(parsed.temperature).toHaveProperty('afternoon'); + expect(parsed.temperature).toHaveProperty('evening'); + } catch (error) { + console.error('Daily aggregation test failed with error:', error); + throw error; + } + }); + + test('overview action with real API key returns weather summary', async () => { + if (!process.env.OPENWEATHER_API_KEY) { + console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.'); + return; + } + + try { + const result = await tool.call({ + action: 'overview', + city: 'London', + units: 'Celsius', + }); + + console.log('Overview API response:', result); + + const parsed = JSON.parse(result); + expect(parsed).toHaveProperty('weather_overview'); + expect(typeof parsed.weather_overview).toBe('string'); + expect(parsed.weather_overview.length).toBeGreaterThan(0); + expect(parsed).toHaveProperty('date'); + expect(parsed).toHaveProperty('units'); + expect(parsed.units).toBe('metric'); + } catch (error) { + console.error('Overview test failed with error:', error); + throw error; + } + }); + + test('different temperature units return correct values', async () => { + if (!process.env.OPENWEATHER_API_KEY) { + console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.'); + return; + } + + try { + // Test Celsius + let result = await tool.call({ + action: 'current_forecast', + city: 'London', + units: 'Celsius', + }); + let parsed = JSON.parse(result); + const celsiusTemp = parsed.current.temp; + + // Test Kelvin + result = await tool.call({ + action: 'current_forecast', + city: 'London', + units: 'Kelvin', + }); + parsed = JSON.parse(result); + const kelvinTemp = parsed.current.temp; + + // Test Fahrenheit + result = await tool.call({ + action: 'current_forecast', + city: 'London', + units: 'Fahrenheit', + }); + parsed = JSON.parse(result); + const fahrenheitTemp = parsed.current.temp; + + // Verify temperature conversions are roughly correct + // K = C + 273.15 + // F = (C * 9/5) + 32 + const celsiusToKelvin = Math.round(celsiusTemp + 273.15); + const celsiusToFahrenheit = Math.round((celsiusTemp * 9) / 5 + 32); + + console.log('Temperature comparisons:', { + celsius: celsiusTemp, + kelvin: kelvinTemp, + fahrenheit: fahrenheitTemp, + calculatedKelvin: celsiusToKelvin, + calculatedFahrenheit: celsiusToFahrenheit, + }); + + // Allow for some rounding differences + expect(Math.abs(kelvinTemp - celsiusToKelvin)).toBeLessThanOrEqual(1); + expect(Math.abs(fahrenheitTemp - celsiusToFahrenheit)).toBeLessThanOrEqual(1); + } catch (error) { + console.error('Temperature units test failed with error:', error); + throw error; + } + }); + + test('language parameter returns localized data', async () => { + if (!process.env.OPENWEATHER_API_KEY) { + console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.'); + return; + } + + try { + // Test with English + let result = await tool.call({ + action: 'current_forecast', + city: 'Paris', + units: 'Celsius', + lang: 'en', + }); + let parsed = JSON.parse(result); + const englishDescription = parsed.current.weather[0].description; + + // Test with French + result = await tool.call({ + action: 'current_forecast', + city: 'Paris', + units: 'Celsius', + lang: 'fr', + }); + parsed = JSON.parse(result); + const frenchDescription = parsed.current.weather[0].description; + + console.log('Language comparison:', { + english: englishDescription, + french: frenchDescription, + }); + + // Verify descriptions are different (indicating translation worked) + expect(englishDescription).not.toBe(frenchDescription); + } catch (error) { + console.error('Language test failed with error:', error); + throw error; + } + }); +}); diff --git a/api/app/clients/tools/structured/specs/openweather.test.js b/api/app/clients/tools/structured/specs/openweather.test.js new file mode 100644 index 00000000000..3340c80cc49 --- /dev/null +++ b/api/app/clients/tools/structured/specs/openweather.test.js @@ -0,0 +1,358 @@ +// __tests__/openweather.test.js +const OpenWeather = require('../OpenWeather'); +const fetch = require('node-fetch'); + +// Mock environment variable +process.env.OPENWEATHER_API_KEY = 'test-api-key'; + +// Mock the fetch function globally +jest.mock('node-fetch', () => jest.fn()); + +describe('OpenWeather Tool', () => { + let tool; + + beforeAll(() => { + tool = new OpenWeather(); + }); + + beforeEach(() => { + fetch.mockReset(); + }); + + test('action=help returns help instructions', async () => { + const result = await tool.call({ + action: 'help', + }); + + expect(typeof result).toBe('string'); + const parsed = JSON.parse(result); + expect(parsed.title).toBe('OpenWeather One Call API 3.0 Help'); + }); + + test('current_forecast with a city and successful geocoding + forecast', async () => { + // Mock geocoding response + fetch.mockImplementationOnce((url) => { + if (url.includes('geo/1.0/direct')) { + return Promise.resolve({ + ok: true, + json: async () => [{ lat: 35.9606, lon: -83.9207 }], + }); + } + return Promise.reject('Unexpected fetch call for geocoding'); + }); + + // Mock forecast response + fetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: async () => ({ + current: { temp: 293.15, feels_like: 295.15 }, + daily: [{ temp: { day: 293.15, night: 283.15 } }], + }), + }), + ); + + const result = await tool.call({ + action: 'current_forecast', + city: 'Knoxville, Tennessee', + units: 'Kelvin', + }); + + const parsed = JSON.parse(result); + expect(parsed.current.temp).toBe(293); + expect(parsed.current.feels_like).toBe(295); + expect(parsed.daily[0].temp.day).toBe(293); + expect(parsed.daily[0].temp.night).toBe(283); + }); + + test('timestamp action with valid date returns mocked historical data', async () => { + // Mock geocoding response + fetch.mockImplementationOnce((url) => { + if (url.includes('geo/1.0/direct')) { + return Promise.resolve({ + ok: true, + json: async () => [{ lat: 35.9606, lon: -83.9207 }], + }); + } + return Promise.reject('Unexpected fetch call for geocoding'); + }); + + // Mock historical weather response + fetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: async () => ({ + data: [ + { + dt: 1583280000, + temp: 283.15, + feels_like: 280.15, + humidity: 75, + weather: [{ description: 'clear sky' }], + }, + ], + }), + }), + ); + + const result = await tool.call({ + action: 'timestamp', + city: 'Knoxville, Tennessee', + date: '2020-03-04', + units: 'Kelvin', + }); + + const parsed = JSON.parse(result); + expect(parsed.data[0].temp).toBe(283); + expect(parsed.data[0].feels_like).toBe(280); + }); + + test('daily_aggregation action returns aggregated weather data', async () => { + // Mock geocoding response + fetch.mockImplementationOnce((url) => { + if (url.includes('geo/1.0/direct')) { + return Promise.resolve({ + ok: true, + json: async () => [{ lat: 35.9606, lon: -83.9207 }], + }); + } + return Promise.reject('Unexpected fetch call for geocoding'); + }); + + // Mock daily aggregation response + fetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: async () => ({ + date: '2020-03-04', + temperature: { + morning: 283.15, + afternoon: 293.15, + evening: 288.15, + }, + humidity: { + morning: 75, + afternoon: 60, + evening: 70, + }, + }), + }), + ); + + const result = await tool.call({ + action: 'daily_aggregation', + city: 'Knoxville, Tennessee', + date: '2020-03-04', + units: 'Kelvin', + }); + + const parsed = JSON.parse(result); + expect(parsed.temperature.morning).toBe(283); + expect(parsed.temperature.afternoon).toBe(293); + expect(parsed.temperature.evening).toBe(288); + }); + + test('overview action returns weather summary', async () => { + // Mock geocoding response + fetch.mockImplementationOnce((url) => { + if (url.includes('geo/1.0/direct')) { + return Promise.resolve({ + ok: true, + json: async () => [{ lat: 35.9606, lon: -83.9207 }], + }); + } + return Promise.reject('Unexpected fetch call for geocoding'); + }); + + // Mock overview response + fetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: async () => ({ + date: '2024-01-07', + lat: 35.9606, + lon: -83.9207, + tz: '+00:00', + units: 'metric', + weather_overview: + 'Currently, the temperature is 2°C with a real feel of -2°C. The sky is clear with moderate wind.', + }), + }), + ); + + const result = await tool.call({ + action: 'overview', + city: 'Knoxville, Tennessee', + units: 'Celsius', + }); + + const parsed = JSON.parse(result); + expect(parsed).toHaveProperty('weather_overview'); + expect(typeof parsed.weather_overview).toBe('string'); + expect(parsed.weather_overview.length).toBeGreaterThan(0); + expect(parsed).toHaveProperty('date'); + expect(parsed).toHaveProperty('units'); + expect(parsed.units).toBe('metric'); + }); + + test('temperature units are correctly converted', async () => { + // Mock geocoding response for all three calls + const geocodingMock = Promise.resolve({ + ok: true, + json: async () => [{ lat: 35.9606, lon: -83.9207 }], + }); + + // Mock weather response for Kelvin + const kelvinMock = Promise.resolve({ + ok: true, + json: async () => ({ + current: { temp: 293.15 }, + }), + }); + + // Mock weather response for Celsius + const celsiusMock = Promise.resolve({ + ok: true, + json: async () => ({ + current: { temp: 20 }, + }), + }); + + // Mock weather response for Fahrenheit + const fahrenheitMock = Promise.resolve({ + ok: true, + json: async () => ({ + current: { temp: 68 }, + }), + }); + + // Test Kelvin + fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => kelvinMock); + + let result = await tool.call({ + action: 'current_forecast', + city: 'Knoxville, Tennessee', + units: 'Kelvin', + }); + let parsed = JSON.parse(result); + expect(parsed.current.temp).toBe(293); + + // Test Celsius + fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => celsiusMock); + + result = await tool.call({ + action: 'current_forecast', + city: 'Knoxville, Tennessee', + units: 'Celsius', + }); + parsed = JSON.parse(result); + expect(parsed.current.temp).toBe(20); + + // Test Fahrenheit + fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => fahrenheitMock); + + result = await tool.call({ + action: 'current_forecast', + city: 'Knoxville, Tennessee', + units: 'Fahrenheit', + }); + parsed = JSON.parse(result); + expect(parsed.current.temp).toBe(68); + }); + + test('timestamp action without a date returns an error message', async () => { + const result = await tool.call({ + action: 'timestamp', + lat: 35.9606, + lon: -83.9207, + }); + expect(result).toMatch( + /Error: For timestamp action, a 'date' in YYYY-MM-DD format is required./, + ); + }); + + test('daily_aggregation action without a date returns an error message', async () => { + const result = await tool.call({ + action: 'daily_aggregation', + lat: 35.9606, + lon: -83.9207, + }); + expect(result).toMatch(/Error: date \(YYYY-MM-DD\) is required for daily_aggregation action./); + }); + + test('unknown action returns an error due to schema validation', async () => { + await expect( + tool.call({ + action: 'unknown_action', + }), + ).rejects.toThrow(/Received tool input did not match expected schema/); + }); + + test('geocoding failure returns a descriptive error', async () => { + fetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: async () => [], + }), + ); + + const result = await tool.call({ + action: 'current_forecast', + city: 'NowhereCity', + }); + expect(result).toMatch(/Error: Could not find coordinates for city: NowhereCity/); + }); + + test('API request failure returns an error', async () => { + // Mock geocoding success + fetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: async () => [{ lat: 35.9606, lon: -83.9207 }], + }), + ); + + // Mock weather request failure + fetch.mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 404, + json: async () => ({ message: 'Not found' }), + }), + ); + + const result = await tool.call({ + action: 'current_forecast', + city: 'Knoxville, Tennessee', + }); + expect(result).toMatch(/Error: OpenWeather API request failed with status 404: Not found/); + }); + + test('invalid date format returns an error', async () => { + // Mock geocoding response first + fetch.mockImplementationOnce((url) => { + if (url.includes('geo/1.0/direct')) { + return Promise.resolve({ + ok: true, + json: async () => [{ lat: 35.9606, lon: -83.9207 }], + }); + } + return Promise.reject('Unexpected fetch call for geocoding'); + }); + + // Mock timestamp API response + fetch.mockImplementationOnce((url) => { + if (url.includes('onecall/timemachine')) { + throw new Error('Invalid date format. Expected YYYY-MM-DD.'); + } + return Promise.reject('Unexpected fetch call'); + }); + + const result = await tool.call({ + action: 'timestamp', + city: 'Knoxville, Tennessee', + date: '03-04-2020', // Wrong format + }); + expect(result).toMatch(/Error: Invalid date format. Expected YYYY-MM-DD./); + }); +}); diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index a8ee50c3d4d..39e710d9400 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -14,6 +14,7 @@ const { TraversaalSearch, StructuredWolfram, TavilySearchResults, + OpenWeather, } = require('../'); const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process'); const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch'); @@ -178,6 +179,7 @@ const loadTools = async ({ 'azure-ai-search': StructuredACS, traversaal_search: TraversaalSearch, tavily_search_results_json: TavilySearchResults, + open_weather: OpenWeather, }; const customConstructors = { diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index c9f84767db8..6da71f908fe 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -1,7 +1,7 @@ const Redis = require('ioredis'); const passport = require('passport'); const session = require('express-session'); -const MemoryStore = require('memorystore')(session) +const MemoryStore = require('memorystore')(session); const RedisStore = require('connect-redis').default; const { setupOpenId, @@ -51,8 +51,8 @@ const configureSocialLogins = (app) => { sessionOptions.store = new RedisStore({ client, prefix: 'librechat' }); } else { sessionOptions.store = new MemoryStore({ - checkPeriod: 86400000 // prune expired entries every 24h - }) + checkPeriod: 86400000, // prune expired entries every 24h + }); } app.use(session(sessionOptions)); app.use(passport.session()); diff --git a/client/public/assets/openweather.png b/client/public/assets/openweather.png new file mode 100644 index 00000000000..0e4d63a2bc2 Binary files /dev/null and b/client/public/assets/openweather.png differ