diff --git a/.env.example b/.env.example index 4146dc3e5a7..76ed026d180 100644 --- a/.env.example +++ b/.env.example @@ -1437,6 +1437,9 @@ HELP_AND_FAQ_URL=https://librechat.ai # OpenWeather # #=====================================================# OPENWEATHER_API_KEY= +# One Call product version. Default 4.0 (new keys). Set 3.0 if this key is a +# legacy One Call 3.0 subscription — 4.0 is a separate product and 3.0 keys 401 there. +# OPENWEATHER_ONECALL_VERSION=4.0 #======================# # Web Search # diff --git a/api/app/clients/tools/structured/OpenWeather.js b/api/app/clients/tools/structured/OpenWeather.js index 9b68f8f2ae9..4d356948e77 100644 --- a/api/app/clients/tools/structured/OpenWeather.js +++ b/api/app/clients/tools/structured/OpenWeather.js @@ -1,119 +1,20 @@ const fetch = require('node-fetch'); const { Tool } = require('@librechat/agents/langchain/tools'); const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env'); - -const openWeatherJsonSchema = { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview'], - description: 'The action to perform', - }, - city: { - type: 'string', - description: 'City name for geocoding if lat/lon not provided', - }, - lat: { - type: 'number', - description: 'Latitude coordinate', - }, - lon: { - type: 'number', - description: 'Longitude coordinate', - }, - exclude: { - type: 'string', - description: 'Parts to exclude from the response', - }, - units: { - type: 'string', - enum: ['Celsius', 'Kelvin', 'Fahrenheit'], - description: 'Temperature units', - }, - lang: { - type: 'string', - description: 'Language code', - }, - date: { - type: 'string', - description: 'Date in YYYY-MM-DD format for timestamp and daily_aggregation', - }, - tz: { - type: 'string', - description: 'Timezone', - }, - }, - required: ['action'], -}; - -/** - * 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; -} +const { + executeOpenWeather, + openWeatherSchema, + OPEN_WEATHER_TOOL_DESCRIPTION, + resolveOpenWeatherOneCallVersion, +} = require('@librechat/api'); 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 = openWeatherJsonSchema; + description = OPEN_WEATHER_TOOL_DESCRIPTION; + schema = openWeatherSchema; static get jsonSchema() { - return openWeatherJsonSchema; + return openWeatherSchema; } constructor(fields = {}) { @@ -131,221 +32,15 @@ class OpenWeather extends Tool { 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); + return await executeOpenWeather(args, { + apiKey: this.apiKey, + fetch, + oneCallVersion: resolveOpenWeatherOneCallVersion( + getEnvironmentVariable('OPENWEATHER_ONECALL_VERSION'), + ), + }); } catch (err) { return `Error: ${err.message}`; } diff --git a/api/app/clients/tools/structured/specs/openWeather.integration.test.js b/api/app/clients/tools/structured/specs/openWeather.integration.test.js index 07dd417cf1a..21f4c8e5b3d 100644 --- a/api/app/clients/tools/structured/specs/openWeather.integration.test.js +++ b/api/app/clients/tools/structured/specs/openWeather.integration.test.js @@ -113,6 +113,8 @@ describe('OpenWeather Tool (Integration Test)', () => { console.log('Overview API response:', result); const parsed = JSON.parse(result); + // One Call 4.0 has no /overview endpoint; this sentence is synthesized from + // current (or daily) conditions rather than OpenWeather's generated summary. expect(parsed).toHaveProperty('weather_overview'); expect(typeof parsed.weather_overview).toBe('string'); expect(parsed.weather_overview.length).toBeGreaterThan(0); diff --git a/api/app/clients/tools/structured/specs/openweather.test.js b/api/app/clients/tools/structured/specs/openweather.test.js index 3340c80cc49..3169bad43f0 100644 --- a/api/app/clients/tools/structured/specs/openweather.test.js +++ b/api/app/clients/tools/structured/specs/openweather.test.js @@ -1,13 +1,68 @@ -// __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()); +const GEO = { lat: 35.9606, lon: -83.9207 }; + +function jsonResponse(body, status = 200) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }); +} + +function mockOpenWeather({ + geo = [GEO], + current, + hourly, + daily, + minutely, + errorStatus, + errorBody, +} = {}) { + fetch.mockImplementation((url) => { + if (url.includes('geo/1.0/direct')) { + return jsonResponse(geo); + } + if (errorStatus) { + return jsonResponse(errorBody ?? { message: 'Not found' }, errorStatus); + } + if (url.includes('/data/4.0/onecall/current')) { + return jsonResponse( + current ?? { + lat: GEO.lat, + lon: GEO.lon, + data: [{ temp: 293.15, feels_like: 295.15 }], + }, + ); + } + if (url.includes('/timeline/1day')) { + return jsonResponse( + daily ?? { + data: [ + { dt: 1583280000, temp: { day: 293.15, night: 283.15, morn: 283.15, eve: 288.15 } }, + ], + }, + ); + } + if (url.includes('/timeline/1h')) { + return jsonResponse( + hourly ?? { + data: [{ dt: 1583280000, temp: 283.15, feels_like: 280.15 }], + }, + ); + } + if (url.includes('/timeline/1min')) { + return jsonResponse(minutely ?? { data: [{ dt: 1, precipitation: 0 }] }); + } + return Promise.reject(new Error(`Unexpected fetch call: ${url}`)); + }); +} + describe('OpenWeather Tool', () => { let tool; @@ -26,32 +81,14 @@ describe('OpenWeather Tool', () => { expect(typeof result).toBe('string'); const parsed = JSON.parse(result); - expect(parsed.title).toBe('OpenWeather One Call API 3.0 Help'); + expect(parsed.title).toBe('OpenWeather One Call API 4.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'); + mockOpenWeather({ + daily: { data: [{ temp: { day: 293.15, night: 283.15 } }] }, }); - // 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', @@ -63,37 +100,15 @@ describe('OpenWeather Tool', () => { expect(parsed.current.feels_like).toBe(295); expect(parsed.daily[0].temp.day).toBe(293); expect(parsed.daily[0].temp.night).toBe(283); + expect(fetch.mock.calls.every(([url]) => !url.includes('/data/3.0/'))).toBe(true); + expect(fetch.mock.calls.some(([url]) => url.includes('/data/4.0/onecall/current'))).toBe(true); + expect(fetch.mock.calls.some(([url]) => url.includes('/timeline/1h'))).toBe(true); + expect(fetch.mock.calls.every(([url]) => !url.includes('/timeline/1min'))).toBe(true); + expect(fetch.mock.calls.some(([url]) => url.includes('units=standard'))).toBe(true); }); 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' }], - }, - ], - }), - }), - ); + mockOpenWeather(); const result = await tool.call({ action: 'timestamp', @@ -105,39 +120,14 @@ describe('OpenWeather Tool', () => { const parsed = JSON.parse(result); expect(parsed.data[0].temp).toBe(283); expect(parsed.data[0].feels_like).toBe(280); + expect(fetch.mock.calls.some(([url]) => url.includes('/data/4.0/onecall/timeline/1h'))).toBe( + true, + ); + expect(fetch.mock.calls.some(([url]) => url.includes('start=1583280000'))).toBe(true); }); 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, - }, - }), - }), - ); + mockOpenWeather(); const result = await tool.call({ action: 'daily_aggregation', @@ -150,36 +140,29 @@ describe('OpenWeather Tool', () => { expect(parsed.temperature.morning).toBe(283); expect(parsed.temperature.afternoon).toBe(293); expect(parsed.temperature.evening).toBe(288); + expect(fetch.mock.calls.some(([url]) => url.includes('/data/4.0/onecall/timeline/1day'))).toBe( + true, + ); }); 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'); + mockOpenWeather({ + current: { + date: '2024-01-07', + lat: 35.9606, + lon: -83.9207, + timezone: 'UTC', + data: [ + { + dt: 1704585600, + temp: 2, + feels_like: -2, + weather: [{ description: 'clear sky' }], + }, + ], + }, }); - // 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', @@ -193,71 +176,74 @@ describe('OpenWeather Tool', () => { expect(parsed).toHaveProperty('date'); expect(parsed).toHaveProperty('units'); expect(parsed.units).toBe('metric'); + expect(fetch.mock.calls.some(([url]) => url.includes('/data/4.0/onecall/current'))).toBe(true); }); 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); - + mockOpenWeather({ current: { data: [{ temp: 293.15 }] } }); 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); + expect(JSON.parse(result).current.temp).toBe(293); + expect(fetch.mock.calls.some(([url]) => url.includes('units=standard'))).toBe(true); + fetch.mockReset(); + mockOpenWeather({ current: { data: [{ temp: 20 }] } }); 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); + expect(JSON.parse(result).current.temp).toBe(20); + expect(fetch.mock.calls.some(([url]) => url.includes('units=metric'))).toBe(true); + fetch.mockReset(); + mockOpenWeather({ current: { data: [{ temp: 68 }] } }); result = await tool.call({ action: 'current_forecast', city: 'Knoxville, Tennessee', units: 'Fahrenheit', }); - parsed = JSON.parse(result); - expect(parsed.current.temp).toBe(68); + expect(JSON.parse(result).current.temp).toBe(68); + expect(fetch.mock.calls.some(([url]) => url.includes('units=imperial'))).toBe(true); + }); + + test('OPENWEATHER_ONECALL_VERSION=3.0 uses the legacy One Call 3.0 path', async () => { + const previous = process.env.OPENWEATHER_ONECALL_VERSION; + process.env.OPENWEATHER_ONECALL_VERSION = '3.0'; + fetch.mockImplementation((url) => { + if (url.includes('geo/1.0/direct')) { + return jsonResponse([GEO]); + } + if (url.includes('/data/3.0/onecall')) { + return jsonResponse({ + lat: GEO.lat, + lon: GEO.lon, + current: { temp: 20.4, feels_like: 18.6 }, + }); + } + return Promise.reject(new Error(`Unexpected fetch call: ${url}`)); + }); + + try { + const result = await tool.call({ + action: 'current_forecast', + city: 'Knoxville, Tennessee', + units: 'Celsius', + }); + const parsed = JSON.parse(result); + expect(parsed.current.temp).toBe(20); + expect(fetch.mock.calls.some(([url]) => url.includes('/data/3.0/onecall'))).toBe(true); + expect(fetch.mock.calls.every(([url]) => !url.includes('/data/4.0/'))).toBe(true); + } finally { + if (previous == null) { + delete process.env.OPENWEATHER_ONECALL_VERSION; + } else { + process.env.OPENWEATHER_ONECALL_VERSION = previous; + } + } }); test('timestamp action without a date returns an error message', async () => { @@ -289,12 +275,7 @@ describe('OpenWeather Tool', () => { }); test('geocoding failure returns a descriptive error', async () => { - fetch.mockImplementationOnce(() => - Promise.resolve({ - ok: true, - json: async () => [], - }), - ); + mockOpenWeather({ geo: [] }); const result = await tool.call({ action: 'current_forecast', @@ -304,22 +285,7 @@ describe('OpenWeather Tool', () => { }); 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' }), - }), - ); + mockOpenWeather({ errorStatus: 404, errorBody: { message: 'Not found' } }); const result = await tool.call({ action: 'current_forecast', @@ -329,29 +295,12 @@ describe('OpenWeather Tool', () => { }); 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'); - }); + mockOpenWeather(); const result = await tool.call({ action: 'timestamp', city: 'Knoxville, Tennessee', - date: '03-04-2020', // Wrong format + date: '03-04-2020', }); expect(result).toMatch(/Error: Invalid date format. Expected YYYY-MM-DD./); }); diff --git a/packages/api/src/telemetry/sdk.spec.ts b/packages/api/src/telemetry/sdk.spec.ts index e5b419da5ed..9e2273058e4 100644 --- a/packages/api/src/telemetry/sdk.spec.ts +++ b/packages/api/src/telemetry/sdk.spec.ts @@ -478,16 +478,16 @@ describe('telemetry SDK lifecycle', () => { const attributes = startSpanHook({ origin: 'https://api.openweathermap.org', - path: '/data/3.0/onecall?appid=OPENWEATHER_SECRET_123&lat=40.71&lon=-74.01', + path: '/data/4.0/onecall/current?appid=OPENWEATHER_SECRET_123&lat=40.71&lon=-74.01', }); expect(attributes).toEqual({ - 'http.target': '/data/3.0/onecall?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + 'http.target': '/data/4.0/onecall/current?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', 'http.url': - 'https://api.openweathermap.org/data/3.0/onecall?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + 'https://api.openweathermap.org/data/4.0/onecall/current?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', 'url.full': - 'https://api.openweathermap.org/data/3.0/onecall?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', - 'url.path': '/data/3.0/onecall', + 'https://api.openweathermap.org/data/4.0/onecall/current?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + 'url.path': '/data/4.0/onecall/current', 'url.query': 'appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', }); expect(JSON.stringify(attributes)).not.toContain('OPENWEATHER_SECRET_123'); diff --git a/packages/api/src/tools/index.ts b/packages/api/src/tools/index.ts index c8cb7fe0045..67e55d6b530 100644 --- a/packages/api/src/tools/index.ts +++ b/packages/api/src/tools/index.ts @@ -6,4 +6,5 @@ export * from './toolkits'; export * from './definitions'; export * from './classification'; export * from './rolePermissions'; +export * from './weather'; export * from './compare'; diff --git a/packages/api/src/tools/registry/definitions.ts b/packages/api/src/tools/registry/definitions.ts index 5cd887e6d65..00e58af501e 100644 --- a/packages/api/src/tools/registry/definitions.ts +++ b/packages/api/src/tools/registry/definitions.ts @@ -149,6 +149,13 @@ export const fluxApiSchema: ExtendedJsonSchema = { }; /** OpenWeather tool JSON schema */ +export const OPEN_WEATHER_TOOL_DESCRIPTION: string = + 'Provides weather data from OpenWeather One Call API 4.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.'; + export const openWeatherSchema: ExtendedJsonSchema = { type: 'object', properties: { @@ -171,7 +178,8 @@ export const openWeatherSchema: ExtendedJsonSchema = { }, exclude: { type: 'string', - description: 'Parts to exclude from the response', + description: + 'Comma-separated current_forecast parts to omit: current, hourly, daily, minutely, alerts. On One Call 4.0, minute precipitation is omitted unless exclude includes +minutely (extra billed call). alerts skips alert resolution.', }, units: { type: 'string', @@ -188,7 +196,8 @@ export const openWeatherSchema: ExtendedJsonSchema = { }, tz: { type: 'string', - description: 'Timezone', + description: + 'Timezone for date-based actions (IANA name such as America/New_York, or ±HH:MM). Used as the local day boundary.', }, }, required: ['action'], @@ -358,8 +367,7 @@ export const toolDefinitions: Record = { }, open_weather: { 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.', + description: OPEN_WEATHER_TOOL_DESCRIPTION, schema: openWeatherSchema, toolType: 'builtin', }, diff --git a/packages/api/src/tools/weather/index.ts b/packages/api/src/tools/weather/index.ts new file mode 100644 index 00000000000..aef77cb289e --- /dev/null +++ b/packages/api/src/tools/weather/index.ts @@ -0,0 +1,38 @@ +export type { + CurrentForecastResult, + DailyAggregationResult, + DailyTemperature, + OneCallRecord, + OneCallResponse, + OpenWeatherArgs, + OpenWeatherDeps, + OpenWeatherFetcher, + OpenWeatherOneCallVersion, + OverviewResult, + PrecipitationAmount, + WeatherAlert, + WeatherCondition, +} from './types'; +export { + convertDateToUnix, + DEFAULT_OPENWEATHER_ONECALL_VERSION, + executeOpenWeather, + getOpenWeatherHelp, + isOpenWeatherPaginationUrl, + OPENWEATHER_API_ORIGIN, + resolveOpenWeatherOneCallVersion, +} from './service'; +export { + collectAlertIds, + dateStringInTimeZone, + isDailyTemperature, + normalizeCurrentForecast, + normalizeDailyAggregation, + omitRecordAlerts, + selectDailyRecord, + stripPagination, + synthesizeOverview, + unixAtLocalMidnight, + utcDateString, +} from './normalize'; +export { mapUnitsToOpenWeather, roundDegree, roundTemperatures, unitSuffix } from './units'; diff --git a/packages/api/src/tools/weather/normalize.spec.ts b/packages/api/src/tools/weather/normalize.spec.ts new file mode 100644 index 00000000000..9ce209c13c7 --- /dev/null +++ b/packages/api/src/tools/weather/normalize.spec.ts @@ -0,0 +1,215 @@ +import type { OneCallRecord, OneCallResponse } from './types'; +import { + collectAlertIds, + dateStringInTimeZone, + normalizeCurrentForecast, + normalizeDailyAggregation, + omitRecordAlerts, + selectDailyRecord, + stripPagination, + synthesizeOverview, + unixAtLocalMidnight, + utcDateString, +} from './normalize'; + +const currentResponse: OneCallResponse = { + lat: 35.9606, + lon: -83.9207, + timezone: 'America/New_York', + timezone_offset: -18000, + data: [ + { + dt: 1583280000, + temp: 20.4, + feels_like: 18.6, + weather: [{ description: 'clear sky' }], + }, + ], +}; + +const dailyResponse: OneCallResponse = { + lat: 35.9606, + lon: -83.9207, + timezone: 'America/New_York', + data: [ + { + dt: 1583298000, + temp: { morn: 10.2, day: 20.4, eve: 15.6, night: 8.1, min: 7.4, max: 21.9 }, + humidity: 70, + clouds: 12, + pressure: 1016, + wind_speed: 4.2, + wind_deg: 180, + weather: [{ description: 'few clouds' }], + }, + ], + next: 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day?appid=SECRET', +}; + +describe('OpenWeather 4.0 normalizers', () => { + it('formats a unix timestamp as a UTC YYYY-MM-DD date', () => { + expect(utcDateString(1583280000)).toBe('2020-03-04'); + }); + + it('formats a unix timestamp in an offset or IANA timezone', () => { + expect(dateStringInTimeZone(1583280000, '-05:00')).toBe('2020-03-03'); + expect(dateStringInTimeZone(1583298000, '-05:00')).toBe('2020-03-04'); + expect(dateStringInTimeZone(1583298000, 'America/New_York')).toBe('2020-03-04'); + }); + + it('converts a calendar date to local midnight unix seconds', () => { + expect(unixAtLocalMidnight(2020, 3, 4)).toBe(1583280000); + expect(unixAtLocalMidnight(2020, 3, 4, '-05:00')).toBe(1583298000); + expect(unixAtLocalMidnight(2020, 3, 4, 'America/New_York')).toBe(1583298000); + expect(unixAtLocalMidnight(2020, 7, 4, 'America/New_York')).toBe( + Math.floor(Date.UTC(2020, 6, 4, 4) / 1000), + ); + }); + + it('lifts 4.0 data arrays into the current/hourly/daily/minutely contract', () => { + const result = normalizeCurrentForecast({ + current: currentResponse, + hourly: { data: [{ dt: 1, temp: 19 }] }, + daily: dailyResponse, + minutely: { data: [{ dt: 1, precipitation: 0 }] }, + }); + + expect(result.lat).toBe(35.9606); + expect(result.lon).toBe(-83.9207); + expect(result.timezone).toBe('America/New_York'); + expect(result.current?.temp).toBe(20.4); + expect(result.hourly?.[0].temp).toBe(19); + expect(result.daily?.[0].temp).toEqual(dailyResponse.data?.[0].temp); + expect(result.minutely?.[0].precipitation).toBe(0); + }); + + it('maps a 4.0 daily record onto the day-summary temperature fields only', () => { + const result = normalizeDailyAggregation(dailyResponse, '2020-03-04', 'metric'); + + expect(result.date).toBe('2020-03-04'); + expect(result.units).toBe('metric'); + expect(result.tz).toBe('America/New_York'); + expect(result.temperature).toEqual({ + min: 7.4, + max: 21.9, + morning: 10.2, + afternoon: 20.4, + evening: 15.6, + night: 8.1, + }); + expect(result.humidity).toBeUndefined(); + expect(result.cloud_cover).toBeUndefined(); + expect(result.pressure).toBeUndefined(); + expect(result.wind).toBeUndefined(); + expect(result.precipitation).toBeUndefined(); + }); + + it.each([ + ['rain-only hourly rate', { rain: { '1h': 2 } }], + ['snow-only hourly rate', { snow: { '1h': 3 } }], + ['mixed hourly rain and snow rates', { rain: { '1h': 2 }, snow: { '1h': 3 } }], + ['unavailable daily totals', {}], + ] as const)('omits precipitation.total for %s', (_label, extra) => { + const response: OneCallResponse = { + lat: 35.9606, + lon: -83.9207, + timezone: 'America/New_York', + data: [ + { + dt: 1583298000, + temp: { morn: 10.2, day: 20.4, eve: 15.6, night: 8.1, min: 7.4, max: 21.9 }, + ...extra, + }, + ], + }; + const result = normalizeDailyAggregation(response, '2020-03-04', 'metric'); + expect(result.precipitation).toBeUndefined(); + }); + + it('selects the daily record whose UTC date matches the request', () => { + const records: OneCallRecord[] = [ + { dt: 1583193600, temp: { day: 1 } }, + { dt: 1583280000, temp: { day: 2 } }, + ]; + expect(selectDailyRecord(records, '2020-03-04')?.temp).toEqual({ day: 2 }); + }); + + it('selects the daily record whose local date matches when tz is set', () => { + const records: OneCallRecord[] = [ + { dt: 1583280000, temp: { day: 1 } }, + { dt: 1583298000, temp: { day: 2 } }, + ]; + expect(selectDailyRecord(records, '2020-03-04', 'America/New_York')?.temp).toEqual({ day: 2 }); + }); + + it('returns undefined when no daily record matches the requested date', () => { + const records: OneCallRecord[] = [ + { dt: Math.floor(Date.UTC(2026, 8, 22) / 1000), temp: { day: 22 } }, + ]; + expect(selectDailyRecord(records, '2026-09-21')).toBeUndefined(); + expect(selectDailyRecord(records, '2026-09-21', 'UTC')).toBeUndefined(); + }); + + it('does not label a different day as the requested daily_aggregation date', () => { + const response: OneCallResponse = { + lat: 1, + lon: 2, + timezone: 'UTC', + data: [ + { + dt: Math.floor(Date.UTC(2026, 8, 22) / 1000), + temp: { morn: 10, day: 22, eve: 16, night: 8, min: 7, max: 23 }, + }, + ], + }; + const result = normalizeDailyAggregation(response, '2026-09-21', 'metric', 'UTC'); + expect(result.date).toBe('2026-09-21'); + expect(result.temperature).toBeUndefined(); + expect(result.precipitation).toBeUndefined(); + }); + + it('stringifies numeric alert ids and drops duplicates', () => { + expect(collectAlertIds([{ alerts: ['abc', 'abc'] }, { alerts: ['def'] }])).toEqual([ + 'abc', + 'def', + ]); + }); + + it('omits alert ids from a record', () => { + expect(omitRecordAlerts({ temp: 20, alerts: ['abc'] })).toEqual({ temp: 20 }); + }); + + it('drops next/prev so pagination URLs never leave the client', () => { + const stripped = stripPagination(dailyResponse); + expect(stripped.next).toBeUndefined(); + expect(stripped.prev).toBeUndefined(); + expect(stripped.data).toEqual(dailyResponse.data); + }); + + it('synthesizes an overview from current conditions with rounded temperatures', () => { + const result = synthesizeOverview({ + current: currentResponse.data?.[0], + date: '2020-03-04', + units: 'metric', + lat: 35.96, + lon: -83.92, + timezone: 'America/New_York', + }); + + expect(result.weather_overview).toBe( + 'Currently, the temperature is 20°C with a real feel of 19°C. The sky is clear sky.', + ); + expect(result.units).toBe('metric'); + expect(result.date).toBe('2020-03-04'); + }); + + it('synthesizes an overview from a daily record when current weather is absent', () => { + const result = synthesizeOverview({ + daily: dailyResponse.data?.[0], + date: '2020-03-04', + units: 'imperial', + }); + + expect(result.weather_overview).toBe('Temperatures range from 7°F to 22°F. Few clouds.'); + }); +}); diff --git a/packages/api/src/tools/weather/normalize.ts b/packages/api/src/tools/weather/normalize.ts new file mode 100644 index 00000000000..b3cfa47990c --- /dev/null +++ b/packages/api/src/tools/weather/normalize.ts @@ -0,0 +1,285 @@ +import type { + CurrentForecastResult, + DailyAggregationResult, + DailyTemperature, + OneCallRecord, + OneCallResponse, + OverviewResult, +} from './types'; + +import { roundDegree, unitSuffix } from './units'; + +const OFFSET_PATTERN = /^([+-])(\d{2}):(\d{2})$/; + +export function isDailyTemperature(value: OneCallRecord['temp']): value is DailyTemperature { + return value != null && typeof value === 'object'; +} + +export function utcDateString(unixSeconds: number): string { + const date = new Date(unixSeconds * 1000); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function parseUtcOffsetSeconds(timeZone: string): number | undefined { + if (timeZone === 'UTC' || timeZone === 'utc' || timeZone === 'Z') { + return 0; + } + const match = OFFSET_PATTERN.exec(timeZone); + if (!match) { + return undefined; + } + const sign = match[1] === '-' ? -1 : 1; + const hours = Number(match[2]); + const minutes = Number(match[3]); + if (hours > 14 || minutes > 59) { + return undefined; + } + return sign * (hours * 3600 + minutes * 60); +} + +function localWallTimeAsUtcMs(instantMs: number, timeZone: string): number | undefined { + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(new Date(instantMs)); + const read = (type: Intl.DateTimeFormatPartTypes): number => { + const value = parts.find((part) => part.type === type)?.value; + return value != null ? Number(value) : NaN; + }; + let hour = read('hour'); + if (hour === 24) { + hour = 0; + } + const utcMs = Date.UTC( + read('year'), + read('month') - 1, + read('day'), + hour, + read('minute'), + read('second'), + ); + return Number.isFinite(utcMs) ? utcMs : undefined; + } catch { + return undefined; + } +} + +export function dateStringInTimeZone(unixSeconds: number, timeZone?: string): string { + if (!timeZone) { + return utcDateString(unixSeconds); + } + const offsetSeconds = parseUtcOffsetSeconds(timeZone); + if (offsetSeconds != null) { + return utcDateString(unixSeconds + offsetSeconds); + } + const partsMs = localWallTimeAsUtcMs(unixSeconds * 1000, timeZone); + if (partsMs == null) { + return utcDateString(unixSeconds); + } + return utcDateString(Math.floor(partsMs / 1000)); +} + +export function unixAtLocalMidnight( + year: number, + month: number, + day: number, + timeZone?: string, +): number { + const utcMidnightMs = Date.UTC(year, month - 1, day, 0, 0, 0); + if (!timeZone) { + return Math.floor(utcMidnightMs / 1000); + } + + const offsetSeconds = parseUtcOffsetSeconds(timeZone); + if (offsetSeconds != null) { + return Math.floor(utcMidnightMs / 1000) - offsetSeconds; + } + + const asUtc = localWallTimeAsUtcMs(utcMidnightMs, timeZone); + if (asUtc == null) { + return Math.floor(utcMidnightMs / 1000); + } + let resultMs = utcMidnightMs - (asUtc - utcMidnightMs); + const asUtcAgain = localWallTimeAsUtcMs(resultMs, timeZone); + if (asUtcAgain != null) { + resultMs = utcMidnightMs - (asUtcAgain - resultMs); + } + return Math.floor(resultMs / 1000); +} + +export function stripPagination(response: OneCallResponse): OneCallResponse { + return { + lat: response.lat, + lon: response.lon, + timezone: response.timezone, + timezone_offset: response.timezone_offset, + data: response.data, + }; +} + +export function collectAlertIds(records: Array): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const record of records) { + for (const id of record?.alerts ?? []) { + if (!seen.has(id)) { + seen.add(id); + ids.push(id); + } + } + } + return ids; +} + +export function omitRecordAlerts(record: OneCallRecord): OneCallRecord { + if (record.alerts == null) { + return record; + } + const rest = { ...record }; + delete rest.alerts; + return rest; +} + +export function normalizeCurrentForecast(parts: { + current?: OneCallResponse; + hourly?: OneCallResponse; + daily?: OneCallResponse; + minutely?: OneCallResponse; +}): CurrentForecastResult { + const meta = parts.current ?? parts.hourly ?? parts.daily ?? parts.minutely; + const result: CurrentForecastResult = { + lat: meta?.lat, + lon: meta?.lon, + timezone: meta?.timezone, + timezone_offset: meta?.timezone_offset, + }; + + if (parts.current?.data?.[0] != null) { + result.current = parts.current.data[0]; + } + if (parts.minutely?.data != null) { + result.minutely = parts.minutely.data; + } + if (parts.hourly?.data != null) { + result.hourly = parts.hourly.data; + } + if (parts.daily?.data != null) { + result.daily = parts.daily.data; + } + + return result; +} + +export function selectDailyRecord( + records: OneCallRecord[], + requestedDate: string, + timeZone?: string, +): OneCallRecord | undefined { + return records.find( + (record) => + typeof record.dt === 'number' && dateStringInTimeZone(record.dt, timeZone) === requestedDate, + ); +} + +function definedFields(value: T): T | undefined { + const entries = Object.entries(value).filter(([, field]) => field !== undefined); + if (entries.length === 0) { + return undefined; + } + return Object.fromEntries(entries) as T; +} + +export function normalizeDailyAggregation( + response: OneCallResponse, + requestedDate: string, + units: string, + timeZone?: string, +): DailyAggregationResult { + const zone = timeZone ?? response.timezone; + const record = selectDailyRecord(response.data ?? [], requestedDate, zone); + const temp = isDailyTemperature(record?.temp) ? record.temp : undefined; + + return { + lat: response.lat, + lon: response.lon, + tz: zone, + date: requestedDate, + units, + temperature: definedFields({ + min: temp?.min, + max: temp?.max, + morning: temp?.morn, + afternoon: temp?.day, + evening: temp?.eve, + night: temp?.night, + }), + }; +} + +function formatOverviewFromCurrent(record: OneCallRecord, units: string): string | undefined { + if (typeof record.temp !== 'number') { + return undefined; + } + const suffix = unitSuffix(units); + const feels = + typeof record.feels_like === 'number' + ? ` with a real feel of ${roundDegree(record.feels_like)}${suffix}` + : ''; + const description = record.weather?.[0]?.description; + const sky = description ? ` The sky is ${description}.` : ''; + return `Currently, the temperature is ${roundDegree(record.temp)}${suffix}${feels}.${sky}`.trim(); +} + +function formatOverviewFromDaily(record: OneCallRecord, units: string): string | undefined { + if (!isDailyTemperature(record.temp)) { + return undefined; + } + const suffix = unitSuffix(units); + const min = record.temp.min; + const max = record.temp.max; + const description = record.weather?.[0]?.description; + const sky = description ? ` ${description.charAt(0).toUpperCase()}${description.slice(1)}.` : ''; + if (typeof min === 'number' && typeof max === 'number') { + return `Temperatures range from ${roundDegree(min)}${suffix} to ${roundDegree(max)}${suffix}.${sky}`.trim(); + } + if (typeof record.temp.day === 'number') { + return `The daytime temperature is ${roundDegree(record.temp.day)}${suffix}.${sky}`.trim(); + } + return undefined; +} + +export function synthesizeOverview(params: { + current?: OneCallRecord; + daily?: OneCallRecord; + date: string; + units: string; + lat?: number; + lon?: number; + timezone?: string; +}): OverviewResult { + const weather_overview = + (params.current != null + ? formatOverviewFromCurrent(params.current, params.units) + : undefined) ?? + (params.daily != null ? formatOverviewFromDaily(params.daily, params.units) : undefined) ?? + 'Weather overview is unavailable for this location.'; + + return { + lat: params.lat, + lon: params.lon, + tz: params.timezone, + date: params.date, + units: params.units, + weather_overview, + }; +} diff --git a/packages/api/src/tools/weather/service.spec.ts b/packages/api/src/tools/weather/service.spec.ts new file mode 100644 index 00000000000..16bbd782fec --- /dev/null +++ b/packages/api/src/tools/weather/service.spec.ts @@ -0,0 +1,764 @@ +import type { OpenWeatherFetcher } from './types'; +import { + convertDateToUnix, + executeOpenWeather, + getOpenWeatherHelp, + isOpenWeatherPaginationUrl, + resolveOpenWeatherOneCallVersion, +} from './service'; +import { OPEN_WEATHER_TOOL_DESCRIPTION } from '../registry/definitions'; + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createFetch(handler: (url: URL) => ReturnType): { + fetch: OpenWeatherFetcher; + urls: string[]; +} { + const urls: string[] = []; + const fetch: OpenWeatherFetcher = async (url) => { + urls.push(url); + return handler(new URL(url)); + }; + return { fetch, urls }; +} + +describe('executeOpenWeather', () => { + const apiKey = 'test-api-key'; + const geoBody = [{ lat: 35.9606, lon: -83.9207 }]; + + it('resolves OPENWEATHER_ONECALL_VERSION to 4.0 unless it is exactly 3.0', () => { + expect(resolveOpenWeatherOneCallVersion()).toBe('4.0'); + expect(resolveOpenWeatherOneCallVersion(undefined)).toBe('4.0'); + expect(resolveOpenWeatherOneCallVersion('4.0')).toBe('4.0'); + expect(resolveOpenWeatherOneCallVersion('3.0')).toBe('3.0'); + expect(resolveOpenWeatherOneCallVersion(' 3.0 ')).toBe('3.0'); + expect(resolveOpenWeatherOneCallVersion('3')).toBe('4.0'); + }); + + it('describes One Call API 4.0 in the tool string and default help payload', () => { + expect(OPEN_WEATHER_TOOL_DESCRIPTION).toContain('One Call API 4.0'); + const help = JSON.parse(getOpenWeatherHelp()) as { + title: string; + notes: string[]; + }; + expect(help.title).toBe('OpenWeather One Call API 4.0 Help'); + expect(help.notes.some((note) => note.includes('OPENWEATHER_ONECALL_VERSION=3.0'))).toBe(true); + expect(help.notes.some((note) => note.includes('up to 20h'))).toBe(true); + }); + + it('describes One Call API 3.0 when that product is selected', () => { + const help = JSON.parse(getOpenWeatherHelp('3.0')) as { title: string }; + expect(help.title).toBe('OpenWeather One Call API 3.0 Help'); + }); + + it('converts a calendar date to a UTC unix timestamp and rejects non-ISO dates', () => { + expect(convertDateToUnix('2020-03-04')).toBe(1583280000); + expect(convertDateToUnix('2020-03-04', '-05:00')).toBe(1583298000); + expect(convertDateToUnix('2020-03-04', 'America/New_York')).toBe(1583298000); + expect(() => convertDateToUnix('03-04-2020')).toThrow( + 'Invalid date format. Expected YYYY-MM-DD.', + ); + }); + + it('accepts only https OpenWeather 4.0 pagination URLs', () => { + expect( + isOpenWeatherPaginationUrl( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?start=1', + ), + ).toBe(true); + expect( + isOpenWeatherPaginationUrl( + 'http://api.openweathermap.org/data/4.0/onecall/timeline/1h?start=1', + ), + ).toBe(false); + expect(isOpenWeatherPaginationUrl('https://evil.example/data/4.0/onecall/timeline/1h')).toBe( + false, + ); + }); + + it('geocodes a city and combines 4.0 current, hourly, and daily without minutely', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/geo/1.0/direct') { + return jsonResponse(geoBody); + } + if (url.pathname === '/data/4.0/onecall/current') { + return jsonResponse({ + lat: 35.9606, + lon: -83.9207, + timezone: 'America/New_York', + data: [{ temp: 293.15, feels_like: 295.15 }], + }); + } + if (url.pathname === '/data/4.0/onecall/timeline/1h') { + return jsonResponse({ data: [{ dt: 1, temp: 291.4 }] }); + } + if (url.pathname === '/data/4.0/onecall/timeline/1day') { + return jsonResponse({ + data: [{ temp: { day: 293.15, night: 283.15 } }], + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const result = JSON.parse( + await executeOpenWeather( + { action: 'current_forecast', city: 'Knoxville, Tennessee', units: 'Kelvin' }, + { apiKey, fetch }, + ), + ) as { + current: { temp: number; feels_like: number }; + daily: Array<{ temp: { day: number; night: number } }>; + hourly: Array<{ temp: number }>; + }; + + expect(result.current.temp).toBe(293); + expect(result.current.feels_like).toBe(295); + expect(result.daily[0].temp.day).toBe(293); + expect(result.daily[0].temp.night).toBe(283); + expect(result.hourly[0].temp).toBe(291); + + const paths = urls.map((url) => new URL(url).pathname); + expect(paths).toContain('/geo/1.0/direct'); + expect(paths).toContain('/data/4.0/onecall/current'); + expect(paths).toContain('/data/4.0/onecall/timeline/1h'); + expect(paths).toContain('/data/4.0/onecall/timeline/1day'); + expect(paths).not.toContain('/data/4.0/onecall/timeline/1min'); + expect(urls.some((url) => url.includes('/data/3.0/'))).toBe(false); + + const hourlyUrl = urls.find((url) => url.includes('/timeline/1h')); + expect(hourlyUrl).toContain('cnt=20'); + + const currentUrl = urls.find((url) => url.includes('/onecall/current')); + expect(currentUrl).toContain('units=standard'); + expect(currentUrl).toContain('appid=test-api-key'); + }); + + it('skips excluded current_forecast parts', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/current') { + return jsonResponse({ data: [{ temp: 20 }] }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const result = JSON.parse( + await executeOpenWeather( + { + action: 'current_forecast', + lat: 35.96, + lon: -83.92, + exclude: 'hourly,daily,minutely', + }, + { apiKey, fetch }, + ), + ) as { current: { temp: number }; hourly?: unknown; daily?: unknown; minutely?: unknown }; + + expect(result.current.temp).toBe(20); + expect(result.hourly).toBeUndefined(); + expect(result.daily).toBeUndefined(); + expect(result.minutely).toBeUndefined(); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain('/data/4.0/onecall/current'); + }); + + it('fetches minutely only when +minutely is requested', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/timeline/1min') { + return jsonResponse({ data: [{ dt: 1, precipitation: 0.2 }] }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const result = JSON.parse( + await executeOpenWeather( + { + action: 'current_forecast', + lat: 35.96, + lon: -83.92, + exclude: 'current,hourly,daily,+minutely', + }, + { apiKey, fetch }, + ), + ) as { minutely: Array<{ precipitation: number }> }; + + expect(result.minutely[0].precipitation).toBe(0.2); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain('/timeline/1min'); + expect(urls[0]).toContain('cnt=60'); + }); + + it('uses timeline/1h with start for timestamp and strips pagination secrets', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/timeline/1h') { + return jsonResponse({ + lat: 35.96, + data: [{ dt: 1583280000, temp: 283.15, feels_like: 280.15 }], + next: 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?appid=SECRET&start=2', + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const raw = await executeOpenWeather( + { action: 'timestamp', lat: 35.96, lon: -83.92, date: '2020-03-04', units: 'Kelvin' }, + { apiKey, fetch }, + ); + const parsed = JSON.parse(raw) as { + data: Array<{ temp: number; feels_like: number }>; + next?: string; + }; + + expect(urls[0]).toContain('/data/4.0/onecall/timeline/1h'); + expect(urls[0]).toContain(`start=${1583280000}`); + expect(urls[0]).toContain('cnt=20'); + expect(parsed.data[0].temp).toBe(283); + expect(parsed.data[0].feels_like).toBe(280); + expect(parsed.next).toBeUndefined(); + expect(raw).not.toContain('SECRET'); + expect(raw).not.toContain('appid='); + }); + + it('does not follow hourly next links by default so one billed page is at most 20h', async () => { + const { fetch, urls } = createFetch((url) => { + if (!url.pathname.endsWith('/timeline/1h')) { + return jsonResponse({ data: [] }); + } + return jsonResponse({ + data: Array.from({ length: 20 }, (_, i) => ({ dt: i, temp: 10 })), + next: 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?start=20&appid=SECRET', + }); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { action: 'current_forecast', lat: 1, lon: 2, exclude: 'current,daily,minutely' }, + { apiKey, fetch }, + ), + ) as { hourly: Array<{ dt: number }> }; + + expect(parsed.hourly).toHaveLength(20); + expect(parsed.hourly[0].dt).toBe(0); + expect(parsed.hourly[19].dt).toBe(19); + expect(urls.filter((url) => url.includes('/timeline/1h'))).toHaveLength(1); + expect(JSON.stringify(parsed)).not.toContain('SECRET'); + }); + + it('refuses to follow pagination off of api.openweathermap.org', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname.endsWith('/timeline/1h')) { + return jsonResponse({ + data: [{ dt: 1, temp: 10 }], + next: 'https://evil.example/data/4.0/onecall/timeline/1h?appid=SECRET', + }); + } + return jsonResponse({ data: [] }); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { action: 'current_forecast', lat: 1, lon: 2, exclude: 'current,daily,minutely' }, + { apiKey, fetch }, + ), + ) as { hourly: Array<{ dt: number }> }; + + expect(parsed.hourly).toHaveLength(1); + expect(urls.filter((url) => url.includes('evil.example'))).toHaveLength(0); + }); + + it('keeps successful current_forecast parts when another endpoint fails', async () => { + const { fetch } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/current') { + return jsonResponse({ data: [{ temp: 21.4 }] }); + } + if (url.pathname === '/data/4.0/onecall/timeline/1h') { + return jsonResponse({ message: 'plan does not include hourly' }, 401); + } + if (url.pathname === '/data/4.0/onecall/timeline/1day') { + return jsonResponse({ data: [{ temp: { day: 22 } }] }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather({ action: 'current_forecast', lat: 1, lon: 2 }, { apiKey, fetch }), + ) as { + current: { temp: number }; + daily: Array<{ temp: { day: number } }>; + hourly?: unknown; + errors: string[]; + }; + + expect(parsed.current.temp).toBe(21); + expect(parsed.daily[0].temp.day).toBe(22); + expect(parsed.hourly).toBeUndefined(); + expect(parsed.errors.some((error) => error.startsWith('hourly:'))).toBe(true); + }); + + it('stringifies numeric alert ids and resolves them only when present', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/current') { + return jsonResponse({ + data: [{ temp: 20, alerts: [1684952747, 'abc-1'] }], + }); + } + if (url.pathname === '/data/4.0/onecall/alert/1684952747') { + return jsonResponse({ + sender_name: 'NWS', + event: 'Flood Warning', + start: 1, + end: 2, + description: 'Flooding expected', + tags: ['Flood'], + }); + } + if (url.pathname === '/data/4.0/onecall/alert/abc-1') { + return jsonResponse({ event: 'Wind Advisory', sender_name: 'NWS' }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { action: 'current_forecast', lat: 1, lon: 2, exclude: 'hourly,daily' }, + { apiKey, fetch }, + ), + ) as { + current: { alerts: string[] }; + alerts: Array<{ id: string; event?: string }>; + }; + + expect(parsed.current.alerts).toEqual(['1684952747', 'abc-1']); + expect(parsed.alerts).toEqual([ + { + id: '1684952747', + sender_name: 'NWS', + event: 'Flood Warning', + start: 1, + end: 2, + description: 'Flooding expected', + tags: ['Flood'], + }, + { id: 'abc-1', event: 'Wind Advisory', sender_name: 'NWS' }, + ]); + expect(urls.some((url) => url.includes('/onecall/alert/1684952747'))).toBe(true); + expect(urls.some((url) => url.includes('/onecall/alert/abc-1'))).toBe(true); + }); + + it('resolves the documented 4.0 opaque alert id and multilingual descriptions', async () => { + const officialAlertId = + '2.49.0.0.250.0.FR.20260827100603.644023:FR615:a512b287066f8f19b5b02accccec835d'; + const officialAlert = { + id: officialAlertId, + sender_name: 'METEO-FRANCE', + event: '', + start: 1787817940, + end: 1787868000, + description: [ + { + language: 'fr-FR', + description: + "Des phénomènes habituels dans la région mais occasionnellement et localement dangereux sont prévus (exemple : mistral, orage d'été, montée des eaux, fortes vagues submergeant le littoral).", + }, + { + language: 'en-GB', + description: + 'Moderate damages may occur, especially in vulnerable or in exposed areas and to people who carry out weather-related activities.', + }, + ], + tags: ['Thunderstorm'], + }; + + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/current') { + return jsonResponse({ + data: [{ temp: 20, alerts: [officialAlertId] }], + }); + } + if (url.pathname.startsWith('/data/4.0/onecall/alert/')) { + const requestedId = decodeURIComponent( + url.pathname.slice('/data/4.0/onecall/alert/'.length), + ); + if (requestedId === officialAlertId) { + return jsonResponse(officialAlert); + } + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { action: 'current_forecast', lat: 1, lon: 2, exclude: 'hourly,daily' }, + { apiKey, fetch }, + ), + ) as { + current: { alerts: string[] }; + alerts: Array<{ + id: string; + sender_name?: string; + event?: string; + description?: string; + tags?: string[]; + }>; + }; + + expect(parsed.current.alerts).toEqual([officialAlertId]); + expect(parsed.alerts).toHaveLength(1); + expect(parsed.alerts[0].id).toBe(officialAlertId); + expect(parsed.alerts[0].sender_name).toBe('METEO-FRANCE'); + expect(parsed.alerts[0].event).toBe(''); + expect(parsed.alerts[0].tags).toEqual(['Thunderstorm']); + expect(parsed.alerts[0].description).toContain( + 'Moderate damages may occur, especially in vulnerable or in exposed areas and to people who carry out weather-related activities.', + ); + expect(parsed.alerts[0].description).toContain( + "Des phénomènes habituels dans la région mais occasionnellement et localement dangereux sont prévus (exemple : mistral, orage d'été, montée des eaux, fortes vagues submergeant le littoral).", + ); + expect(urls.some((url) => url.includes(encodeURIComponent(officialAlertId)))).toBe(true); + }); + + it('skips path-navigating alert ids without requesting the detail endpoint', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/current') { + return jsonResponse({ + data: [{ temp: 20, alerts: ['../secret', 'foo/bar', 'abc-1'] }], + }); + } + if (url.pathname === '/data/4.0/onecall/alert/abc-1') { + return jsonResponse({ event: 'Wind Advisory', sender_name: 'NWS' }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { action: 'current_forecast', lat: 1, lon: 2, exclude: 'hourly,daily' }, + { apiKey, fetch }, + ), + ) as { alerts?: Array<{ id: string }>; errors?: string[] }; + + expect(parsed.alerts).toEqual([{ id: 'abc-1', event: 'Wind Advisory', sender_name: 'NWS' }]); + expect(parsed.errors?.some((error) => error.includes('../secret'))).toBe(true); + expect(parsed.errors?.some((error) => error.includes('foo/bar'))).toBe(true); + expect(urls.some((url) => url.includes('../') || url.includes('foo/bar'))).toBe(false); + }); + + it('does not resolve alerts when they are excluded', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/current') { + return jsonResponse({ + data: [{ temp: 20, alerts: ['abc-1'] }], + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { action: 'current_forecast', lat: 1, lon: 2, exclude: 'hourly,daily,alerts' }, + { apiKey, fetch }, + ), + ) as { current: { alerts?: string[] }; alerts?: unknown }; + + expect(parsed.current.alerts).toBeUndefined(); + expect(parsed.alerts).toBeUndefined(); + expect(urls.some((url) => url.includes('/onecall/alert/'))).toBe(false); + }); + + it('maps timeline/1day onto the daily_aggregation contract', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/timeline/1day') { + return jsonResponse({ + timezone: 'America/New_York', + data: [ + { + dt: 1583298000, + temp: { morn: 283.15, day: 293.15, eve: 288.15, night: 280 }, + }, + ], + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { + action: 'daily_aggregation', + lat: 35.96, + lon: -83.92, + date: '2020-03-04', + units: 'Kelvin', + tz: 'America/New_York', + }, + { apiKey, fetch }, + ), + ) as { temperature: { morning: number; afternoon: number; evening: number }; tz: string }; + + expect(urls[0]).toContain('/data/4.0/onecall/timeline/1day'); + expect(urls[0]).toContain(`start=${1583298000}`); + expect(parsed.temperature.morning).toBe(283); + expect(parsed.temperature.afternoon).toBe(293); + expect(parsed.temperature.evening).toBe(288); + expect(parsed.tz).toBe('America/New_York'); + }); + + it('returns no daily_aggregation temperature when the requested date is absent', async () => { + const { fetch } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/timeline/1day') { + return jsonResponse({ + timezone: 'UTC', + data: [ + { + dt: Math.floor(Date.UTC(2026, 8, 22) / 1000), + temp: { morn: 10, day: 22, eve: 16, night: 8, min: 7, max: 23 }, + }, + ], + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { + action: 'daily_aggregation', + lat: 1, + lon: 2, + date: '2026-09-21', + units: 'Celsius', + }, + { apiKey, fetch }, + ), + ) as { date: string; temperature?: { afternoon?: number } }; + + expect(parsed.date).toBe('2026-09-21'); + expect(parsed.temperature).toBeUndefined(); + }); + + it.each([ + ['rain-only hourly rate', { rain: { '1h': 2 } }], + ['snow-only hourly rate', { snow: { '1h': 3 } }], + ['mixed hourly rain and snow rates', { rain: { '1h': 2 }, snow: { '1h': 3 } }], + ['unavailable daily totals', {}], + ] as const)('omits daily_aggregation precipitation.total for %s', async (_label, extra) => { + const { fetch } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/timeline/1day') { + return jsonResponse({ + timezone: 'America/New_York', + data: [ + { + dt: 1583298000, + temp: { morn: 10.2, day: 20.4, eve: 15.6, night: 8.1, min: 7.4, max: 21.9 }, + ...extra, + }, + ], + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { + action: 'daily_aggregation', + lat: 35.96, + lon: -83.92, + date: '2020-03-04', + tz: 'America/New_York', + }, + { apiKey, fetch }, + ), + ) as { precipitation?: { total?: number }; temperature?: { afternoon?: number } }; + + expect(parsed.temperature?.afternoon).toBe(20); + expect(parsed.precipitation).toBeUndefined(); + }); + + it('synthesizes overview from current weather when 4.0 has no overview endpoint', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/current') { + return jsonResponse({ + lat: 35.9606, + lon: -83.9207, + timezone: 'America/New_York', + data: [ + { + dt: 1704585600, + temp: 2, + feels_like: -2, + weather: [{ description: 'clear sky' }], + }, + ], + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { action: 'overview', lat: 35.9606, lon: -83.9207, units: 'Celsius' }, + { apiKey, fetch }, + ), + ) as { weather_overview: string; units: string; date: string }; + + expect(urls[0]).toContain('/data/4.0/onecall/current'); + expect(parsed.units).toBe('metric'); + expect(parsed.date).toBe('2024-01-06'); + expect(parsed.weather_overview).toContain('2°C'); + expect(parsed.weather_overview).toContain('-2°C'); + expect(parsed.weather_overview.length).toBeGreaterThan(0); + }); + + it('does not synthesize a dated overview from a different day', async () => { + const { fetch } = createFetch((url) => { + if (url.pathname === '/data/4.0/onecall/timeline/1day') { + return jsonResponse({ + lat: 1, + lon: 2, + timezone: 'UTC', + data: [ + { + dt: Math.floor(Date.UTC(2026, 8, 22) / 1000), + temp: { min: 7.4, max: 21.9, day: 22 }, + weather: [{ description: 'few clouds' }], + }, + ], + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { + action: 'overview', + lat: 1, + lon: 2, + date: '2026-09-21', + units: 'Celsius', + }, + { apiKey, fetch }, + ), + ) as { date: string; weather_overview: string }; + + expect(parsed.date).toBe('2026-09-21'); + expect(parsed.weather_overview).toBe('Weather overview is unavailable for this location.'); + expect(parsed.weather_overview).not.toContain('22'); + expect(parsed.weather_overview).not.toContain('7'); + }); + + it('uses One Call 3.0 endpoints when that product is selected', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/geo/1.0/direct') { + return jsonResponse(geoBody); + } + if (url.pathname === '/data/3.0/onecall') { + return jsonResponse({ + lat: 35.9606, + current: { temp: 20.4, feels_like: 18.6 }, + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { action: 'current_forecast', city: 'Knoxville, Tennessee' }, + { apiKey, fetch, oneCallVersion: '3.0' }, + ), + ) as { current: { temp: number; feels_like: number } }; + + expect(parsed.current.temp).toBe(20); + expect(parsed.current.feels_like).toBe(19); + expect(urls.some((url) => url.includes('/data/3.0/onecall'))).toBe(true); + expect(urls.some((url) => url.includes('/data/4.0/'))).toBe(false); + + const help = JSON.parse( + await executeOpenWeather({ action: 'help' }, { apiKey, fetch, oneCallVersion: '3.0' }), + ) as { title: string }; + expect(help.title).toBe('OpenWeather One Call API 3.0 Help'); + }); + + it('passes tz through to the 3.0 day_summary endpoint', async () => { + const { fetch, urls } = createFetch((url) => { + if (url.pathname === '/data/3.0/onecall/day_summary') { + return jsonResponse({ + date: '2020-03-04', + temperature: { morning: 10.2, afternoon: 20.4, evening: 15.6 }, + }); + } + throw new Error(`unexpected url ${url.toString()}`); + }); + + const parsed = JSON.parse( + await executeOpenWeather( + { + action: 'daily_aggregation', + lat: 35.96, + lon: -83.92, + date: '2020-03-04', + tz: '-05:00', + }, + { apiKey, fetch, oneCallVersion: '3.0' }, + ), + ) as { temperature: { morning: number } }; + + expect(parsed.temperature.morning).toBe(10); + expect(urls[0]).toContain('/data/3.0/onecall/day_summary'); + expect(urls[0]).toContain('date=2020-03-04'); + expect(urls[0]).toContain('tz=-05%3A00'); + }); + + it('returns the original error strings for missing dates and missing coordinates', async () => { + const fetch: OpenWeatherFetcher = async () => { + throw new Error('fetch should not run'); + }; + + expect( + await executeOpenWeather({ action: 'timestamp', lat: 1, lon: 2 }, { apiKey, fetch }), + ).toBe("Error: For timestamp action, a 'date' in YYYY-MM-DD format is required."); + expect( + await executeOpenWeather({ action: 'daily_aggregation', lat: 1, lon: 2 }, { apiKey, fetch }), + ).toBe('Error: date (YYYY-MM-DD) is required for daily_aggregation action.'); + expect(await executeOpenWeather({ action: 'current_forecast' }, { apiKey, fetch })).toBe( + "Error: lat and lon are required and must be numbers for this action (or specify 'city').", + ); + expect(await executeOpenWeather({ action: 'unknown_action' }, { apiKey, fetch })).toBe( + 'Error: Unknown action: unknown_action', + ); + }); + + it('returns a descriptive error when geocoding finds nothing', async () => { + const { fetch } = createFetch(() => jsonResponse([])); + const result = await executeOpenWeather( + { action: 'current_forecast', city: 'NowhereCity' }, + { apiKey, fetch }, + ); + expect(result).toBe('Error: Could not find coordinates for city: NowhereCity'); + }); + + it('returns OpenWeather HTTP failures without calling a live API', async () => { + const { fetch } = createFetch((url) => { + if (url.pathname === '/geo/1.0/direct') { + return jsonResponse(geoBody); + } + return jsonResponse({ message: 'Not found' }, 404); + }); + + const result = await executeOpenWeather( + { + action: 'current_forecast', + city: 'Knoxville, Tennessee', + exclude: 'hourly,daily,minutely', + }, + { apiKey, fetch }, + ); + expect(result).toBe('Error: OpenWeather API request failed with status 404: Not found'); + }); +}); diff --git a/packages/api/src/tools/weather/service.ts b/packages/api/src/tools/weather/service.ts new file mode 100644 index 00000000000..3d4affcb061 --- /dev/null +++ b/packages/api/src/tools/weather/service.ts @@ -0,0 +1,1001 @@ +import type { + CurrentForecastResult, + DailyAggregationResult, + OneCallRecord, + OneCallResponse, + OpenWeatherArgs, + OpenWeatherDeps, + OpenWeatherOneCallVersion, + OverviewResult, + WeatherAlert, +} from './types'; +import { + collectAlertIds, + dateStringInTimeZone, + normalizeCurrentForecast, + normalizeDailyAggregation, + omitRecordAlerts, + selectDailyRecord, + synthesizeOverview, + unixAtLocalMidnight, +} from './normalize'; +import { mapUnitsToOpenWeather, roundTemperatures } from './units'; + +export const OPENWEATHER_API_ORIGIN: string = 'https://api.openweathermap.org'; +export const DEFAULT_OPENWEATHER_ONECALL_VERSION: OpenWeatherOneCallVersion = '4.0'; + +const GEOCODE_PATH = '/geo/1.0/direct'; +const CURRENT_PATH = '/data/4.0/onecall/current'; +const MINUTELY_PATH = '/data/4.0/onecall/timeline/1min'; +const HOURLY_PATH = '/data/4.0/onecall/timeline/1h'; +const DAILY_PATH = '/data/4.0/onecall/timeline/1day'; +const ALERT_PATH_PREFIX = '/data/4.0/onecall/alert/'; +const ONE_CALL_3_PATH = '/data/3.0/onecall'; +const HOURLY_PAGE_LIMIT = 1; +const HOURLY_RECORD_LIMIT = 20; +const HOURLY_PAGE_SIZE = 20; +const DAILY_PAGE_SIZE = 10; +const MINUTELY_COUNT = 60; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const ALERT_PATH_HAZARD = /[/\\]|\.\.|%(?:2e|2f|5c)/i; +const EXCLUDE_PARTS = new Set(['current', 'minutely', 'hourly', 'daily', 'alerts']); +const COORDINATE_ACTIONS = new Set([ + 'current_forecast', + 'timestamp', + 'daily_aggregation', + 'overview', +]); + +class OpenWeatherApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'OpenWeatherApiError'; + this.status = status; + } +} + +export function resolveOpenWeatherOneCallVersion(value?: string | null): OpenWeatherOneCallVersion { + return value?.trim() === '3.0' ? '3.0' : DEFAULT_OPENWEATHER_ONECALL_VERSION; +} + +function resolvedOneCallVersion(deps: OpenWeatherDeps): OpenWeatherOneCallVersion { + return deps.oneCallVersion === '3.0' ? '3.0' : DEFAULT_OPENWEATHER_ONECALL_VERSION; +} + +export function getOpenWeatherHelp(version: OpenWeatherOneCallVersion = '4.0'): string { + if (version === '3.0') { + 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', 'tz'], + 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: [ + 'This deployment is using One Call 3.0 via OPENWEATHER_ONECALL_VERSION=3.0.', + 'New OpenWeather keys should use the 4.0 default (unset or OPENWEATHER_ONECALL_VERSION=4.0).', + '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, + ); + } + + return JSON.stringify( + { + title: 'OpenWeather One Call API 4.0 Help', + description: 'Guidance on using the OpenWeather One Call API 4.0.', + endpoints: { + current_and_forecast: { + endpoint: 'data/4.0/onecall/current + timeline/1h + timeline/1day', + data_provided: [ + 'Current weather', + 'Hourly forecast (up to 20h, one page)', + 'Daily forecast (up to 10 days)', + 'Resolved weather alerts when IDs are present', + ], + 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/4.0/onecall/timeline/1h', + data_provided: [ + 'Hourly weather around a date (up to 20h per page; history since 1979, forecast up to 48h 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: 'Fahrenheit', + lang: 'en', + }, + }, + daily_aggregation: { + endpoint: 'data/4.0/onecall/timeline/1day', + data_provided: [ + 'Daily weather for a specific date (history since 1979, forecast up 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: 'synthesized from data/4.0/onecall/current or timeline/1day', + data_provided: [ + 'Human-readable weather summary. One Call 4.0 has no overview endpoint, so this is composed from current or daily data.', + ], + required_params: [['lat', 'lon'], ['city']], + optional_params: ['date (YYYY-MM-DD)', 'units (Celsius/Kelvin/Fahrenheit)', 'tz'], + 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.', + 'One Call 4.0 bills per HTTP call. current_forecast uses current + hourly (one page, up to 20h) + daily. The 1-hour timeline returns at most 20 records per response. Minute precipitation is omitted by default; include +minutely in exclude to fetch it (extra billed call).', + 'Existing One Call 3.0 subscriptions still work. Set OPENWEATHER_ONECALL_VERSION=3.0 to keep using /data/3.0. Default is 4.0 for new keys.', + 'current_forecast maps 4.0 split endpoints onto the previous current/hourly/daily contract. Failed endpoints are noted in errors instead of failing the whole action.', + 'daily_aggregation maps 4.0 daily temp.morn/day/eve/night onto morning/afternoon/evening/night. Period humidity/cloud/pressure/wind and precipitation.total are omitted when 4.0 does not provide a documented daily accumulation. rain.1h / snow.1h are mm/h rates, not a daily total.', + 'Date-based actions honour tz (IANA name or ±HH:MM) as the local day boundary.', + 'Weather alert IDs are resolved through /onecall/alert/{id} only when alerts are present. exclude=alerts skips that.', + ], + 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, + ); +} + +export function convertDateToUnix(dateStr: string, timeZone?: string): number { + if (!DATE_PATTERN.test(dateStr)) { + throw new Error('Invalid date format. Expected YYYY-MM-DD.'); + } + const parts = dateStr.split('-'); + 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 utcMidnight = Date.UTC(year, month - 1, day, 0, 0, 0); + if (isNaN(utcMidnight)) { + throw new Error('Invalid date provided. Cannot parse into a valid date.'); + } + + return unixAtLocalMidnight(year, month, day, timeZone); +} + +export function isOpenWeatherPaginationUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.protocol === 'https:' && + parsed.hostname === 'api.openweathermap.org' && + parsed.pathname.startsWith('/data/4.0/onecall/') + ); + } catch { + return false; + } +} + +function isObject(value: unknown): value is { [key: string]: unknown } { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function asIdArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return value.flatMap((item) => { + if (typeof item === 'string' && item.length > 0) { + return [item]; + } + if (typeof item === 'number' && Number.isFinite(item)) { + return [String(item)]; + } + return []; + }); +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const items = value.filter((item): item is string => typeof item === 'string'); + return items; +} + +function parseWeatherCondition(value: unknown): OneCallRecord['weather'] { + if (!Array.isArray(value)) { + return undefined; + } + const conditions = value.flatMap((item) => { + if (!isObject(item)) { + return []; + } + return [ + { + id: asNumber(item.id), + main: asString(item.main), + description: asString(item.description), + icon: asString(item.icon), + }, + ]; + }); + return conditions; +} + +function parsePrecipitation(value: unknown): OneCallRecord['rain'] { + if (typeof value === 'number') { + return value; + } + if (!isObject(value)) { + return undefined; + } + return { '1h': asNumber(value['1h']) }; +} + +function parseTemperature(value: unknown): OneCallRecord['temp'] { + if (typeof value === 'number') { + return value; + } + if (!isObject(value)) { + return undefined; + } + return { + day: asNumber(value.day), + min: asNumber(value.min), + max: asNumber(value.max), + night: asNumber(value.night), + eve: asNumber(value.eve), + morn: asNumber(value.morn), + }; +} + +function parseOneCallRecord(value: unknown): OneCallRecord | undefined { + if (!isObject(value)) { + return undefined; + } + return { + dt: asNumber(value.dt), + sunrise: asNumber(value.sunrise), + sunset: asNumber(value.sunset), + moonrise: asNumber(value.moonrise), + moonset: asNumber(value.moonset), + moon_phase: asNumber(value.moon_phase), + temp: parseTemperature(value.temp), + feels_like: parseTemperature(value.feels_like), + pressure: asNumber(value.pressure), + humidity: asNumber(value.humidity), + dew_point: asNumber(value.dew_point), + uvi: asNumber(value.uvi), + clouds: asNumber(value.clouds), + visibility: asNumber(value.visibility), + wind_speed: asNumber(value.wind_speed), + wind_deg: asNumber(value.wind_deg), + wind_gust: asNumber(value.wind_gust), + pop: asNumber(value.pop), + precipitation: asNumber(value.precipitation), + rain: parsePrecipitation(value.rain), + snow: parsePrecipitation(value.snow), + weather: parseWeatherCondition(value.weather), + alerts: asIdArray(value.alerts), + }; +} + +function parseOneCallResponse(body: unknown): OneCallResponse { + if (!isObject(body)) { + return {}; + } + const data = Array.isArray(body.data) + ? body.data.flatMap((item) => { + const record = parseOneCallRecord(item); + return record ? [record] : []; + }) + : undefined; + return { + lat: asNumber(body.lat), + lon: asNumber(body.lon), + timezone: asString(body.timezone), + timezone_offset: asNumber(body.timezone_offset), + data, + next: asString(body.next), + prev: asString(body.prev), + }; +} + +function isEnglishAlertLanguage(language?: string): boolean { + return language != null && /^en(?:-|$)/i.test(language); +} + +function parseAlertDescription(value: unknown): string | undefined { + if (typeof value === 'string') { + return value.length > 0 ? value : undefined; + } + if (!Array.isArray(value)) { + return undefined; + } + + const entries = value.flatMap((item) => { + if (!isObject(item)) { + return []; + } + const description = asString(item.description); + if (description == null || description.length === 0) { + return []; + } + return [{ language: asString(item.language), description }]; + }); + if (entries.length === 0) { + return undefined; + } + if (entries.length === 1) { + return entries[0].description; + } + + const preferred = entries.find((entry) => isEnglishAlertLanguage(entry.language)) ?? entries[0]; + const rest = entries.filter((entry) => entry !== preferred); + return [preferred, ...rest] + .map((entry) => + entry.language != null ? `[${entry.language}] ${entry.description}` : entry.description, + ) + .join('\n'); +} + +function parseWeatherAlert(body: unknown, id: string): WeatherAlert | undefined { + const source = isObject(body) && isObject(body.data) ? body.data : body; + if (!isObject(source)) { + return undefined; + } + return { + id, + sender_name: asString(source.sender_name), + event: asString(source.event), + start: asNumber(source.start), + end: asNumber(source.end), + description: parseAlertDescription(source.description), + tags: asStringArray(source.tags), + }; +} + +function isSafeAlertId(id: string): boolean { + if (id.length === 0 || id.includes('\0')) { + return false; + } + return !ALERT_PATH_HAZARD.test(id); +} + +function readErrorMessage(body: unknown): string { + if (isObject(body) && typeof body.message === 'string') { + return body.message; + } + try { + return JSON.stringify(body); + } catch { + return 'Unknown error'; + } +} + +function parseExclude(exclude?: string): Set { + if (!exclude) { + return new Set(); + } + return new Set( + exclude + .split(',') + .map((part) => part.trim().toLowerCase()) + .filter((part) => EXCLUDE_PARTS.has(part)), + ); +} + +function requestsMinutely(exclude?: string): boolean { + if (!exclude) { + return false; + } + return exclude.split(',').some((part) => part.trim().toLowerCase() === '+minutely'); +} + +function buildUrl(path: string, params: URLSearchParams): string { + return `${OPENWEATHER_API_ORIGIN}${path}?${params.toString()}`; +} + +function locationParams(args: { + apiKey: string; + lat: number; + lon: number; + units: string; + lang?: string; +}): URLSearchParams { + const params = new URLSearchParams({ + appid: args.apiKey, + units: args.units, + lat: String(args.lat), + lon: String(args.lon), + }); + if (args.lang) { + params.append('lang', args.lang); + } + return params; +} + +async function fetchOpenWeatherBody( + url: string, + fetchImpl: OpenWeatherDeps['fetch'], +): Promise { + const response = await fetchImpl(url); + const body: unknown = await response.json(); + if (!response.ok) { + throw new OpenWeatherApiError( + response.status, + `OpenWeather API request failed with status ${response.status}: ${readErrorMessage(body)}`, + ); + } + return body; +} + +async function fetchOpenWeatherJson( + url: string, + fetchImpl: OpenWeatherDeps['fetch'], +): Promise { + return parseOneCallResponse(await fetchOpenWeatherBody(url, fetchImpl)); +} + +async function fetchTimeline( + path: string, + params: URLSearchParams, + fetchImpl: OpenWeatherDeps['fetch'], + options?: { pages?: number; maxRecords?: number }, +): Promise { + const first = await fetchOpenWeatherJson(buildUrl(path, params), fetchImpl); + const pageLimit = options?.pages ?? 1; + const maxRecords = options?.maxRecords; + const data = [...(first.data ?? [])]; + let next = first.next; + let pageCount = 1; + + while ( + next && + pageCount < pageLimit && + (maxRecords == null || data.length < maxRecords) && + isOpenWeatherPaginationUrl(next) + ) { + const extra = await fetchOpenWeatherJson(next, fetchImpl); + if (extra.data) { + data.push(...extra.data); + } + next = extra.next; + pageCount += 1; + } + + if (maxRecords != null && data.length > maxRecords) { + data.length = maxRecords; + } + + return { + lat: first.lat, + lon: first.lon, + timezone: first.timezone, + timezone_offset: first.timezone_offset, + data, + }; +} + +async function geocodeCity( + city: string, + deps: OpenWeatherDeps, +): Promise<{ lat: number; lon: number }> { + const params = new URLSearchParams({ + q: city, + limit: '1', + appid: deps.apiKey, + }); + const url = buildUrl(GEOCODE_PATH, params); + const response = await deps.fetch(url); + const body: unknown = await response.json(); + if (!response.ok || !Array.isArray(body) || body.length === 0) { + throw new Error(`Could not find coordinates for city: ${city}`); + } + const first = body[0]; + if (!isObject(first) || typeof first.lat !== 'number' || typeof first.lon !== 'number') { + throw new Error(`Could not find coordinates for city: ${city}`); + } + return { lat: first.lat, lon: first.lon }; +} + +function stringifyResult( + value: CurrentForecastResult | DailyAggregationResult | OverviewResult | OneCallResponse, +): string { + return JSON.stringify(roundTemperatures(value)); +} + +function stripAlertsFromForecast(result: CurrentForecastResult): CurrentForecastResult { + return { + ...result, + current: result.current != null ? omitRecordAlerts(result.current) : result.current, + minutely: result.minutely?.map(omitRecordAlerts), + hourly: result.hourly?.map(omitRecordAlerts), + daily: result.daily?.map(omitRecordAlerts), + }; +} + +async function resolveAlerts( + records: Array, + deps: OpenWeatherDeps, +): Promise<{ alerts: WeatherAlert[]; errors: string[] }> { + const ids = collectAlertIds(records); + if (ids.length === 0) { + return { alerts: [], errors: [] }; + } + + const settled = await Promise.allSettled( + ids.map(async (id) => { + if (!isSafeAlertId(id)) { + throw new Error(`Skipping unsafe alert id`); + } + const params = new URLSearchParams({ appid: deps.apiKey }); + const body = await fetchOpenWeatherBody( + buildUrl(`${ALERT_PATH_PREFIX}${encodeURIComponent(id)}`, params), + deps.fetch, + ); + const alert = parseWeatherAlert(body, id); + if (alert == null) { + throw new Error(`Could not parse alert ${id}`); + } + return alert; + }), + ); + + const alerts: WeatherAlert[] = []; + const errors: string[] = []; + for (let i = 0; i < settled.length; i++) { + const result = settled[i]; + const id = ids[i]; + if (result.status === 'fulfilled') { + alerts.push(result.value); + } else { + const message = result.reason instanceof Error ? result.reason.message : 'Unknown error'; + errors.push(`alert ${id}: ${message}`); + } + } + return { alerts, errors }; +} + +type ForecastPartName = 'current' | 'hourly' | 'daily' | 'minutely'; + +async function currentForecast( + args: OpenWeatherArgs, + coords: { lat: number; lon: number }, + units: string, + deps: OpenWeatherDeps, +): Promise { + const excluded = parseExclude(args.exclude); + const includeCurrent = !excluded.has('current'); + const includeHourly = !excluded.has('hourly'); + const includeDaily = !excluded.has('daily'); + const includeMinutely = !excluded.has('minutely') && requestsMinutely(args.exclude); + const includeAlerts = !excluded.has('alerts'); + const params = () => + locationParams({ + apiKey: deps.apiKey, + lat: coords.lat, + lon: coords.lon, + units, + lang: args.lang, + }); + + const requests: Array<{ name: ForecastPartName; promise: Promise }> = []; + if (includeCurrent) { + requests.push({ + name: 'current', + promise: fetchOpenWeatherJson(buildUrl(CURRENT_PATH, params()), deps.fetch), + }); + } + if (includeHourly) { + const hourlyParams = params(); + hourlyParams.set('cnt', String(HOURLY_PAGE_SIZE)); + requests.push({ + name: 'hourly', + promise: fetchTimeline(HOURLY_PATH, hourlyParams, deps.fetch, { + pages: HOURLY_PAGE_LIMIT, + maxRecords: HOURLY_RECORD_LIMIT, + }), + }); + } + if (includeDaily) { + const dailyParams = params(); + dailyParams.set('cnt', String(DAILY_PAGE_SIZE)); + requests.push({ + name: 'daily', + promise: fetchTimeline(DAILY_PATH, dailyParams, deps.fetch), + }); + } + if (includeMinutely) { + const minutelyParams = params(); + minutelyParams.set('cnt', String(MINUTELY_COUNT)); + requests.push({ + name: 'minutely', + promise: fetchOpenWeatherJson(buildUrl(MINUTELY_PATH, minutelyParams), deps.fetch), + }); + } + + const settled = await Promise.allSettled(requests.map((request) => request.promise)); + const parts: { + current?: OneCallResponse; + hourly?: OneCallResponse; + daily?: OneCallResponse; + minutely?: OneCallResponse; + } = {}; + const partErrors: Array<{ name: ForecastPartName; message: string }> = []; + + for (let i = 0; i < settled.length; i++) { + const name = requests[i].name; + const result = settled[i]; + if (result.status === 'fulfilled') { + parts[name] = result.value; + } else { + const message = result.reason instanceof Error ? result.reason.message : 'Unknown error'; + partErrors.push({ name, message }); + } + } + + if ( + parts.current == null && + parts.hourly == null && + parts.daily == null && + parts.minutely == null + ) { + return `Error: ${partErrors[0]?.message ?? 'OpenWeather API request failed'}`; + } + + const result = normalizeCurrentForecast(parts); + const errors = partErrors.map(({ name, message }) => `${name}: ${message}`); + + if (includeAlerts) { + const resolved = await resolveAlerts( + [ + result.current, + ...(result.hourly ?? []), + ...(result.daily ?? []), + ...(result.minutely ?? []), + ], + deps, + ); + if (resolved.alerts.length > 0) { + result.alerts = resolved.alerts; + } + errors.push(...resolved.errors); + } else { + const stripped = stripAlertsFromForecast(result); + result.current = stripped.current; + result.minutely = stripped.minutely; + result.hourly = stripped.hourly; + result.daily = stripped.daily; + } + + if (errors.length > 0) { + result.errors = errors; + } + + return stringifyResult(result); +} + +async function timestampForecast( + args: OpenWeatherArgs, + coords: { lat: number; lon: number }, + units: string, + deps: OpenWeatherDeps, +): Promise { + if (!args.date) { + return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required."; + } + const start = convertDateToUnix(args.date, args.tz); + const params = locationParams({ + apiKey: deps.apiKey, + lat: coords.lat, + lon: coords.lon, + units, + lang: args.lang, + }); + params.set('start', String(start)); + params.set('cnt', String(HOURLY_PAGE_SIZE)); + const response = await fetchTimeline(HOURLY_PATH, params, deps.fetch); + return stringifyResult(response); +} + +async function dailyAggregation( + args: OpenWeatherArgs, + coords: { lat: number; lon: number }, + units: string, + deps: OpenWeatherDeps, +): Promise { + if (!args.date) { + return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.'; + } + const start = convertDateToUnix(args.date, args.tz); + const params = locationParams({ + apiKey: deps.apiKey, + lat: coords.lat, + lon: coords.lon, + units, + lang: args.lang, + }); + params.set('start', String(start)); + params.set('cnt', String(DAILY_PAGE_SIZE)); + const response = await fetchTimeline(DAILY_PATH, params, deps.fetch); + return stringifyResult(normalizeDailyAggregation(response, args.date, units, args.tz)); +} + +async function overviewForecast( + args: OpenWeatherArgs, + coords: { lat: number; lon: number }, + units: string, + deps: OpenWeatherDeps, +): Promise { + const params = locationParams({ + apiKey: deps.apiKey, + lat: coords.lat, + lon: coords.lon, + units, + lang: args.lang, + }); + + if (args.date) { + const start = convertDateToUnix(args.date, args.tz); + params.set('start', String(start)); + params.set('cnt', String(DAILY_PAGE_SIZE)); + const daily = await fetchTimeline(DAILY_PATH, params, deps.fetch); + const zone = args.tz ?? daily.timezone; + const record = selectDailyRecord(daily.data ?? [], args.date, zone); + return stringifyResult( + synthesizeOverview({ + daily: record, + date: args.date, + units, + lat: daily.lat ?? coords.lat, + lon: daily.lon ?? coords.lon, + timezone: zone, + }), + ); + } + + const current = await fetchOpenWeatherJson(buildUrl(CURRENT_PATH, params), deps.fetch); + const record: OneCallRecord | undefined = current.data?.[0]; + const zone = args.tz ?? current.timezone; + const date = + record?.dt != null + ? dateStringInTimeZone(record.dt, zone) + : dateStringInTimeZone(Math.floor(Date.now() / 1000), zone); + return stringifyResult( + synthesizeOverview({ + current: record, + date, + units, + lat: current.lat ?? coords.lat, + lon: current.lon ?? coords.lon, + timezone: zone, + }), + ); +} + +async function executeOneCall3( + args: OpenWeatherArgs, + coords: { lat: number; lon: number }, + units: string, + deps: OpenWeatherDeps, +): Promise { + const params = new URLSearchParams({ + appid: deps.apiKey, + units, + lat: String(coords.lat), + lon: String(coords.lon), + }); + if (args.lang) { + params.append('lang', args.lang); + } + + let path: string; + switch (args.action) { + case 'current_forecast': + path = ONE_CALL_3_PATH; + if (args.exclude) { + params.append('exclude', args.exclude); + } + break; + case 'timestamp': + if (!args.date) { + return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required."; + } + path = `${ONE_CALL_3_PATH}/timemachine`; + params.append('dt', String(convertDateToUnix(args.date, args.tz))); + break; + case 'daily_aggregation': + if (!args.date) { + return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.'; + } + path = `${ONE_CALL_3_PATH}/day_summary`; + params.append('date', args.date); + if (args.tz) { + params.append('tz', args.tz); + } + break; + case 'overview': + path = `${ONE_CALL_3_PATH}/overview`; + if (args.date) { + params.append('date', args.date); + } + break; + default: + return `Error: Unknown action: ${args.action}`; + } + + const body = await fetchOpenWeatherBody(buildUrl(path, params), deps.fetch); + return JSON.stringify(roundTemperatures(body)); +} + +export async function executeOpenWeather( + args: OpenWeatherArgs, + deps: OpenWeatherDeps, +): Promise { + try { + const { action, city, lat, lon, units } = args; + const owmUnits = mapUnitsToOpenWeather(units); + const version = resolvedOneCallVersion(deps); + + if (action === 'help') { + return getOpenWeatherHelp(version); + } + + if (!COORDINATE_ACTIONS.has(action)) { + return `Error: Unknown action: ${action}`; + } + + let finalLat = lat; + let finalLon = lon; + if ((finalLat == null || finalLon == null) && city) { + const coords = await geocodeCity(city, deps); + finalLat = coords.lat; + finalLon = coords.lon; + } + + 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 coords = { lat: finalLat, lon: finalLon }; + + if (version === '3.0') { + return await executeOneCall3(args, coords, owmUnits, deps); + } + + switch (action) { + case 'current_forecast': + return await currentForecast(args, coords, owmUnits, deps); + case 'timestamp': + return await timestampForecast(args, coords, owmUnits, deps); + case 'daily_aggregation': + return await dailyAggregation(args, coords, owmUnits, deps); + case 'overview': + return await overviewForecast(args, coords, owmUnits, deps); + default: + return `Error: Unknown action: ${action}`; + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return `Error: ${message}`; + } +} diff --git a/packages/api/src/tools/weather/types.ts b/packages/api/src/tools/weather/types.ts new file mode 100644 index 00000000000..8f491e6c3fe --- /dev/null +++ b/packages/api/src/tools/weather/types.ts @@ -0,0 +1,144 @@ +export type OpenWeatherFetcher = (url: string) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; +}>; + +export type OpenWeatherOneCallVersion = '3.0' | '4.0'; + +export type OpenWeatherDeps = { + apiKey: string; + fetch: OpenWeatherFetcher; + oneCallVersion?: OpenWeatherOneCallVersion; +}; + +export type OpenWeatherArgs = { + action: string; + city?: string; + lat?: number; + lon?: number; + exclude?: string; + units?: string; + lang?: string; + date?: string; + tz?: string; +}; + +export type WeatherCondition = { + id?: number; + main?: string; + description?: string; + icon?: string; +}; + +export type DailyTemperature = { + day?: number; + min?: number; + max?: number; + night?: number; + eve?: number; + morn?: number; +}; + +export type PrecipitationAmount = { + '1h'?: number; +}; + +export type WeatherAlert = { + id?: string; + sender_name?: string; + event?: string; + start?: number; + end?: number; + description?: string; + tags?: string[]; +}; + +export type OneCallRecord = { + dt?: number; + sunrise?: number; + sunset?: number; + moonrise?: number; + moonset?: number; + moon_phase?: number; + temp?: number | DailyTemperature; + feels_like?: number | DailyTemperature; + pressure?: number; + humidity?: number; + dew_point?: number; + uvi?: number; + clouds?: number; + visibility?: number; + wind_speed?: number; + wind_deg?: number; + wind_gust?: number; + pop?: number; + precipitation?: number; + rain?: number | PrecipitationAmount; + snow?: number | PrecipitationAmount; + weather?: WeatherCondition[]; + alerts?: string[]; +}; + +export type OneCallResponse = { + lat?: number; + lon?: number; + timezone?: string; + timezone_offset?: number; + data?: OneCallRecord[]; + next?: string; + prev?: string; +}; + +export type CurrentForecastResult = { + lat?: number; + lon?: number; + timezone?: string; + timezone_offset?: number; + current?: OneCallRecord; + minutely?: OneCallRecord[]; + hourly?: OneCallRecord[]; + daily?: OneCallRecord[]; + alerts?: WeatherAlert[]; + errors?: string[]; +}; + +export type DailyAggregationResult = { + lat?: number; + lon?: number; + tz?: string; + date: string; + units: string; + cloud_cover?: { afternoon?: number }; + humidity?: { + morning?: number; + afternoon?: number; + evening?: number; + night?: number; + }; + precipitation?: { total?: number }; + temperature?: { + min?: number; + max?: number; + morning?: number; + afternoon?: number; + evening?: number; + night?: number; + }; + pressure?: { afternoon?: number }; + wind?: { + max?: { + speed?: number; + direction?: number; + }; + }; +}; + +export type OverviewResult = { + lat?: number; + lon?: number; + tz?: string; + date: string; + units: string; + weather_overview: string; +}; diff --git a/packages/api/src/tools/weather/units.spec.ts b/packages/api/src/tools/weather/units.spec.ts new file mode 100644 index 00000000000..d571847085d --- /dev/null +++ b/packages/api/src/tools/weather/units.spec.ts @@ -0,0 +1,40 @@ +import { mapUnitsToOpenWeather, roundDegree, roundTemperatures, unitSuffix } from './units'; + +describe('OpenWeather units', () => { + it('maps display units onto OpenWeather units and defaults to metric', () => { + expect(mapUnitsToOpenWeather()).toBe('metric'); + expect(mapUnitsToOpenWeather('Celsius')).toBe('metric'); + expect(mapUnitsToOpenWeather('Kelvin')).toBe('standard'); + expect(mapUnitsToOpenWeather('Fahrenheit')).toBe('imperial'); + expect(mapUnitsToOpenWeather('Rankine')).toBe('metric'); + }); + + it('returns a display suffix for each OpenWeather unit', () => { + expect(unitSuffix('metric')).toBe('°C'); + expect(unitSuffix('imperial')).toBe('°F'); + expect(unitSuffix('standard')).toBe(' K'); + }); + + it('rounds a temperature to the nearest degree', () => { + expect(roundDegree(20.4)).toBe(20); + expect(roundDegree(18.6)).toBe(19); + expect(roundDegree(7.4)).toBe(7); + expect(roundDegree(21.9)).toBe(22); + }); + + it('rounds nested temperature fields and leaves non-temp numbers intact', () => { + const rounded = roundTemperatures({ + current: { temp: 293.15, feels_like: 295.4, humidity: 75, dew_point: 280.6 }, + daily: [{ temp: { day: 293.15, night: 283.49, min: 279.9, max: 294.2 } }], + humidity: 60.4, + }); + + expect(rounded.current.temp).toBe(293); + expect(rounded.current.feels_like).toBe(295); + expect(rounded.current.humidity).toBe(75); + expect(rounded.current.dew_point).toBe(281); + expect(rounded.daily[0].temp.day).toBe(293); + expect(rounded.daily[0].temp.night).toBe(283); + expect(rounded.humidity).toBe(60.4); + }); +}); diff --git a/packages/api/src/tools/weather/units.ts b/packages/api/src/tools/weather/units.ts new file mode 100644 index 00000000000..74e3ed471f4 --- /dev/null +++ b/packages/api/src/tools/weather/units.ts @@ -0,0 +1,69 @@ +const TEMPERATURE_KEYS = new Set([ + 'temp', + 'feels_like', + 'dew_point', + 'day', + 'min', + 'max', + 'night', + 'eve', + 'morn', + 'afternoon', + 'morning', + 'evening', +]); + +/** + * Map user-friendly units to OpenWeather units. + * Defaults to Celsius if not specified. + */ +export function mapUnitsToOpenWeather(unit?: string): string { + if (!unit) { + return 'metric'; + } + switch (unit) { + case 'Celsius': + return 'metric'; + case 'Kelvin': + return 'standard'; + case 'Fahrenheit': + return 'imperial'; + default: + return 'metric'; + } +} + +export function unitSuffix(units: string): string { + if (units === 'imperial') { + return '°F'; + } + if (units === 'standard') { + return ' K'; + } + return '°C'; +} + +export function roundDegree(value: number): number { + return Math.round(value); +} + +/** + * Recursively round temperature fields in the API response. + */ +export function roundTemperatures(obj: T): T { + if (Array.isArray(obj)) { + return obj.map((item) => roundTemperatures(item)) as T; + } + if (obj && typeof obj === 'object') { + const record = obj as { [key: string]: unknown }; + for (const key of Object.keys(record)) { + const value = record[key]; + if (value && typeof value === 'object') { + record[key] = roundTemperatures(value); + } else if (typeof value === 'number' && TEMPERATURE_KEYS.has(key)) { + record[key] = Math.round(value); + } + } + } + return obj; +}