Skip to content

Commit 0855677

Browse files
🌤️ 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 <[email protected]>
1 parent ea1a5c8 commit 0855677

File tree

9 files changed

+927
-6
lines changed

9 files changed

+927
-6
lines changed

.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
256256
# DALLE3_AZURE_API_VERSION=
257257
# DALLE2_AZURE_API_VERSION=
258258

259+
259260
# Google
260261
#-----------------
261262
GOOGLE_SEARCH_API_KEY=
@@ -514,4 +515,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
514515

515516
# no-cache: Forces validation with server before using cached version
516517
# no-store: Prevents storing the response entirely
517-
# must-revalidate: Prevents using stale content when offline
518+
# must-revalidate: Prevents using stale content when offline
519+
520+
#=====================================================#
521+
# OpenWeather #
522+
#=====================================================#
523+
OPENWEATHER_API_KEY=

api/app/clients/tools/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const StructuredSD = require('./structured/StableDiffusion');
88
const GoogleSearchAPI = require('./structured/GoogleSearch');
99
const TraversaalSearch = require('./structured/TraversaalSearch');
1010
const TavilySearchResults = require('./structured/TavilySearchResults');
11+
const OpenWeather = require('./structured/OpenWeather');
1112

1213
module.exports = {
1314
availableTools,
@@ -19,4 +20,5 @@ module.exports = {
1920
TraversaalSearch,
2021
StructuredWolfram,
2122
TavilySearchResults,
23+
OpenWeather,
2224
};

api/app/clients/tools/manifest.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@
100100
"pluginKey": "calculator",
101101
"description": "Perform simple and complex mathematical calculations.",
102102
"icon": "https://i.imgur.com/RHsSG5h.png",
103-
"isAuthRequired": "false",
104103
"authConfig": []
105104
},
106105
{
@@ -135,7 +134,20 @@
135134
{
136135
"authField": "AZURE_AI_SEARCH_API_KEY",
137136
"label": "Azure AI Search API Key",
138-
"description": "You need to provideq your API Key for Azure AI Search."
137+
"description": "You need to provide your API Key for Azure AI Search."
138+
}
139+
]
140+
},
141+
{
142+
"name": "OpenWeather",
143+
"pluginKey": "open_weather",
144+
"description": "Get weather forecasts and historical data from the OpenWeather API",
145+
"icon": "/assets/openweather.png",
146+
"authConfig": [
147+
{
148+
"authField": "OPENWEATHER_API_KEY",
149+
"label": "OpenWeather API Key",
150+
"description": "Sign up at <a href=\"https://home.openweathermap.org/users/sign_up\" target=\"_blank\">OpenWeather</a>, then get your key at <a href=\"https://home.openweathermap.org/api_keys\" target=\"_blank\">API keys</a>."
139151
}
140152
]
141153
}
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
const { Tool } = require('@langchain/core/tools');
2+
const { z } = require('zod');
3+
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
4+
const fetch = require('node-fetch');
5+
6+
/**
7+
* Map user-friendly units to OpenWeather units.
8+
* Defaults to Celsius if not specified.
9+
*/
10+
function mapUnitsToOpenWeather(unit) {
11+
if (!unit) {
12+
return 'metric';
13+
} // Default to Celsius
14+
switch (unit) {
15+
case 'Celsius':
16+
return 'metric';
17+
case 'Kelvin':
18+
return 'standard';
19+
case 'Fahrenheit':
20+
return 'imperial';
21+
default:
22+
return 'metric'; // fallback
23+
}
24+
}
25+
26+
/**
27+
* Recursively round temperature fields in the API response.
28+
*/
29+
function roundTemperatures(obj) {
30+
const tempKeys = new Set([
31+
'temp',
32+
'feels_like',
33+
'dew_point',
34+
'day',
35+
'min',
36+
'max',
37+
'night',
38+
'eve',
39+
'morn',
40+
'afternoon',
41+
'morning',
42+
'evening',
43+
]);
44+
45+
if (Array.isArray(obj)) {
46+
return obj.map((item) => roundTemperatures(item));
47+
} else if (obj && typeof obj === 'object') {
48+
for (const key of Object.keys(obj)) {
49+
const value = obj[key];
50+
if (value && typeof value === 'object') {
51+
obj[key] = roundTemperatures(value);
52+
} else if (typeof value === 'number' && tempKeys.has(key)) {
53+
obj[key] = Math.round(value);
54+
}
55+
}
56+
}
57+
return obj;
58+
}
59+
60+
class OpenWeather extends Tool {
61+
name = 'open_weather';
62+
description =
63+
'Provides weather data from OpenWeather One Call API 3.0. ' +
64+
'Actions: help, current_forecast, timestamp, daily_aggregation, overview. ' +
65+
'If lat/lon not provided, specify "city" for geocoding. ' +
66+
'Units: "Celsius", "Kelvin", or "Fahrenheit" (default: Celsius). ' +
67+
'For timestamp action, use "date" in YYYY-MM-DD format.';
68+
69+
schema = z.object({
70+
action: z.enum(['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview']),
71+
city: z.string().optional(),
72+
lat: z.number().optional(),
73+
lon: z.number().optional(),
74+
exclude: z.string().optional(),
75+
units: z.enum(['Celsius', 'Kelvin', 'Fahrenheit']).optional(),
76+
lang: z.string().optional(),
77+
date: z.string().optional(), // For timestamp and daily_aggregation
78+
tz: z.string().optional(),
79+
});
80+
81+
constructor(fields = {}) {
82+
super();
83+
this.envVar = 'OPENWEATHER_API_KEY';
84+
this.override = fields.override ?? false;
85+
this.apiKey = fields[this.envVar] ?? this.getApiKey();
86+
}
87+
88+
getApiKey() {
89+
const key = getEnvironmentVariable(this.envVar);
90+
if (!key && !this.override) {
91+
throw new Error(`Missing ${this.envVar} environment variable.`);
92+
}
93+
return key;
94+
}
95+
96+
async geocodeCity(city) {
97+
const geocodeUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent(
98+
city,
99+
)}&limit=1&appid=${this.apiKey}`;
100+
const res = await fetch(geocodeUrl);
101+
const data = await res.json();
102+
if (!res.ok || !Array.isArray(data) || data.length === 0) {
103+
throw new Error(`Could not find coordinates for city: ${city}`);
104+
}
105+
return { lat: data[0].lat, lon: data[0].lon };
106+
}
107+
108+
convertDateToUnix(dateStr) {
109+
const parts = dateStr.split('-');
110+
if (parts.length !== 3) {
111+
throw new Error('Invalid date format. Expected YYYY-MM-DD.');
112+
}
113+
const year = parseInt(parts[0], 10);
114+
const month = parseInt(parts[1], 10);
115+
const day = parseInt(parts[2], 10);
116+
if (isNaN(year) || isNaN(month) || isNaN(day)) {
117+
throw new Error('Invalid date format. Expected YYYY-MM-DD with valid numbers.');
118+
}
119+
120+
const dateObj = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
121+
if (isNaN(dateObj.getTime())) {
122+
throw new Error('Invalid date provided. Cannot parse into a valid date.');
123+
}
124+
125+
return Math.floor(dateObj.getTime() / 1000);
126+
}
127+
128+
async _call(args) {
129+
try {
130+
const { action, city, lat, lon, exclude, units, lang, date, tz } = args;
131+
const owmUnits = mapUnitsToOpenWeather(units);
132+
133+
if (action === 'help') {
134+
return JSON.stringify(
135+
{
136+
title: 'OpenWeather One Call API 3.0 Help',
137+
description: 'Guidance on using the OpenWeather One Call API 3.0.',
138+
endpoints: {
139+
current_and_forecast: {
140+
endpoint: 'data/3.0/onecall',
141+
data_provided: [
142+
'Current weather',
143+
'Minute forecast (1h)',
144+
'Hourly forecast (48h)',
145+
'Daily forecast (8 days)',
146+
'Government weather alerts',
147+
],
148+
required_params: [['lat', 'lon'], ['city']],
149+
optional_params: ['exclude', 'units (Celsius/Kelvin/Fahrenheit)', 'lang'],
150+
usage_example: {
151+
city: 'Knoxville, Tennessee',
152+
units: 'Fahrenheit',
153+
lang: 'en',
154+
},
155+
},
156+
weather_for_timestamp: {
157+
endpoint: 'data/3.0/onecall/timemachine',
158+
data_provided: [
159+
'Historical weather (since 1979-01-01)',
160+
'Future forecast up to 4 days ahead',
161+
],
162+
required_params: [
163+
['lat', 'lon', 'date (YYYY-MM-DD)'],
164+
['city', 'date (YYYY-MM-DD)'],
165+
],
166+
optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang'],
167+
usage_example: {
168+
city: 'Knoxville, Tennessee',
169+
date: '2020-03-04',
170+
units: 'Fahrenheit',
171+
lang: 'en',
172+
},
173+
},
174+
daily_aggregation: {
175+
endpoint: 'data/3.0/onecall/day_summary',
176+
data_provided: [
177+
'Aggregated weather data for a specific date (1979-01-02 to 1.5 years ahead)',
178+
],
179+
required_params: [
180+
['lat', 'lon', 'date (YYYY-MM-DD)'],
181+
['city', 'date (YYYY-MM-DD)'],
182+
],
183+
optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang', 'tz'],
184+
usage_example: {
185+
city: 'Knoxville, Tennessee',
186+
date: '2020-03-04',
187+
units: 'Celsius',
188+
lang: 'en',
189+
},
190+
},
191+
weather_overview: {
192+
endpoint: 'data/3.0/onecall/overview',
193+
data_provided: ['Human-readable weather summary (today/tomorrow)'],
194+
required_params: [['lat', 'lon'], ['city']],
195+
optional_params: ['date (YYYY-MM-DD)', 'units (Celsius/Kelvin/Fahrenheit)'],
196+
usage_example: {
197+
city: 'Knoxville, Tennessee',
198+
date: '2024-05-13',
199+
units: 'Celsius',
200+
},
201+
},
202+
},
203+
notes: [
204+
'If lat/lon not provided, you can specify a city name and it will be geocoded.',
205+
'For the timestamp action, provide a date in YYYY-MM-DD format instead of a Unix timestamp.',
206+
'By default, temperatures are returned in Celsius.',
207+
'You can specify units as Celsius, Kelvin, or Fahrenheit.',
208+
'All temperatures are rounded to the nearest degree.',
209+
],
210+
errors: [
211+
'400: Bad Request (missing/invalid params)',
212+
'401: Unauthorized (check API key)',
213+
'404: Not Found (no data or city)',
214+
'429: Too many requests',
215+
'5xx: Internal error',
216+
],
217+
},
218+
null,
219+
2,
220+
);
221+
}
222+
223+
let finalLat = lat;
224+
let finalLon = lon;
225+
226+
// If lat/lon not provided but city is given, geocode it
227+
if ((finalLat == null || finalLon == null) && city) {
228+
const coords = await this.geocodeCity(city);
229+
finalLat = coords.lat;
230+
finalLon = coords.lon;
231+
}
232+
233+
if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) {
234+
if (typeof finalLat !== 'number' || typeof finalLon !== 'number') {
235+
return 'Error: lat and lon are required and must be numbers for this action (or specify \'city\').';
236+
}
237+
}
238+
239+
const baseUrl = 'https://api.openweathermap.org/data/3.0';
240+
let endpoint = '';
241+
const params = new URLSearchParams({ appid: this.apiKey, units: owmUnits });
242+
243+
let dt;
244+
if (action === 'timestamp') {
245+
if (!date) {
246+
return 'Error: For timestamp action, a \'date\' in YYYY-MM-DD format is required.';
247+
}
248+
dt = this.convertDateToUnix(date);
249+
}
250+
251+
if (action === 'daily_aggregation' && !date) {
252+
return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.';
253+
}
254+
255+
switch (action) {
256+
case 'current_forecast':
257+
endpoint = '/onecall';
258+
params.append('lat', String(finalLat));
259+
params.append('lon', String(finalLon));
260+
if (exclude) {
261+
params.append('exclude', exclude);
262+
}
263+
if (lang) {
264+
params.append('lang', lang);
265+
}
266+
break;
267+
case 'timestamp':
268+
endpoint = '/onecall/timemachine';
269+
params.append('lat', String(finalLat));
270+
params.append('lon', String(finalLon));
271+
params.append('dt', String(dt));
272+
if (lang) {
273+
params.append('lang', lang);
274+
}
275+
break;
276+
case 'daily_aggregation':
277+
endpoint = '/onecall/day_summary';
278+
params.append('lat', String(finalLat));
279+
params.append('lon', String(finalLon));
280+
params.append('date', date);
281+
if (lang) {
282+
params.append('lang', lang);
283+
}
284+
if (tz) {
285+
params.append('tz', tz);
286+
}
287+
break;
288+
case 'overview':
289+
endpoint = '/onecall/overview';
290+
params.append('lat', String(finalLat));
291+
params.append('lon', String(finalLon));
292+
if (date) {
293+
params.append('date', date);
294+
}
295+
break;
296+
default:
297+
return `Error: Unknown action: ${action}`;
298+
}
299+
300+
const url = `${baseUrl}${endpoint}?${params.toString()}`;
301+
const response = await fetch(url);
302+
const json = await response.json();
303+
if (!response.ok) {
304+
return `Error: OpenWeather API request failed with status ${response.status}: ${
305+
json.message || JSON.stringify(json)
306+
}`;
307+
}
308+
309+
const roundedJson = roundTemperatures(json);
310+
return JSON.stringify(roundedJson);
311+
} catch (err) {
312+
return `Error: ${err.message}`;
313+
}
314+
}
315+
}
316+
317+
module.exports = OpenWeather;

0 commit comments

Comments
 (0)