-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
🌤️ 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>
- Loading branch information
1 parent
ea1a5c8
commit 0855677
Showing
9 changed files
with
927 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
Oops, something went wrong.