mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 01:10:14 +01:00
🌤️ feat: Add OpenWeather Tool for Weather Data Retrieval (#5246)
* ✨ feat: Add OpenWeather Tool for Weather Data Retrieval 🌤️ * chore: linting * chore: move test files * fix: tool icon, allow user-provided keys, conform to app key assignment pattern * chore: linting not included in #5212 --------- Co-authored-by: Jonathan Addington <jonathan.addington@jmaddington.com>
This commit is contained in:
parent
ea1a5c8a30
commit
0855677a36
9 changed files with 927 additions and 6 deletions
358
api/app/clients/tools/structured/specs/openweather.test.js
Normal file
358
api/app/clients/tools/structured/specs/openweather.test.js
Normal file
|
|
@ -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./);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue