From ad45fade3c7c705340c623912356aeee4a6ce5c8 Mon Sep 17 00:00:00 2001 From: GokayAI <60583610+gokay-ai@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:24:30 +0000 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=8C=A4=EF=B8=8F=20fix:=20Migrate=20Op?= =?UTF-8?q?enWeather=20Tool=20to=20One=20Call=20API=204.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenWeather no longer sells One Call 3.0, so a new API key 401s on the hardcoded data/3.0 endpoints. Call 4.0 instead and map its split payloads back onto the existing current/hourly/daily, timestamp, daily aggregation, and overview contract. Signed-off-by: GokayAI <60583610+gokay-ai@users.noreply.github.com> --- .../clients/tools/structured/OpenWeather.js | 330 +--------- .../structured/specs/openweather.test.js | 295 +++------ packages/api/src/telemetry/sdk.spec.ts | 10 +- packages/api/src/tools/index.ts | 1 + .../api/src/tools/registry/definitions.ts | 4 +- packages/api/src/tools/weather/index.ts | 31 + .../api/src/tools/weather/normalize.spec.ts | 128 ++++ packages/api/src/tools/weather/normalize.ts | 185 ++++++ .../api/src/tools/weather/service.spec.ts | 345 ++++++++++ packages/api/src/tools/weather/service.ts | 619 ++++++++++++++++++ packages/api/src/tools/weather/types.ts | 129 ++++ packages/api/src/tools/weather/units.spec.ts | 33 + packages/api/src/tools/weather/units.ts | 65 ++ 13 files changed, 1654 insertions(+), 521 deletions(-) create mode 100644 packages/api/src/tools/weather/index.ts create mode 100644 packages/api/src/tools/weather/normalize.spec.ts create mode 100644 packages/api/src/tools/weather/normalize.ts create mode 100644 packages/api/src/tools/weather/service.spec.ts create mode 100644 packages/api/src/tools/weather/service.ts create mode 100644 packages/api/src/tools/weather/types.ts create mode 100644 packages/api/src/tools/weather/units.spec.ts create mode 100644 packages/api/src/tools/weather/units.ts diff --git a/api/app/clients/tools/structured/OpenWeather.js b/api/app/clients/tools/structured/OpenWeather.js index 9b68f8f2ae9..6770bc83f64 100644 --- a/api/app/clients/tools/structured/OpenWeather.js +++ b/api/app/clients/tools/structured/OpenWeather.js @@ -1,119 +1,19 @@ 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, +} = 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 +31,9 @@ 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 }); } catch (err) { return `Error: ${err.message}`; } diff --git a/api/app/clients/tools/structured/specs/openweather.test.js b/api/app/clients/tools/structured/specs/openweather.test.js index 3340c80cc49..ab494769cb4 100644 --- a/api/app/clients/tools/structured/specs/openweather.test.js +++ b/api/app/clients/tools/structured/specs/openweather.test.js @@ -1,13 +1,66 @@ -// __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 +79,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 +98,13 @@ 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('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 +116,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 +136,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 +172,38 @@ 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('timestamp action without a date returns an error message', async () => { @@ -289,12 +235,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 +245,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 +255,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 d901755b7cd..6904575d917 100644 --- a/packages/api/src/tools/index.ts +++ b/packages/api/src/tools/index.ts @@ -6,3 +6,4 @@ export * from './toolkits'; export * from './definitions'; export * from './classification'; export * from './rolePermissions'; +export * from './weather'; diff --git a/packages/api/src/tools/registry/definitions.ts b/packages/api/src/tools/registry/definitions.ts index 3063940df4b..0f1dafca202 100644 --- a/packages/api/src/tools/registry/definitions.ts +++ b/packages/api/src/tools/registry/definitions.ts @@ -1,6 +1,7 @@ import { WebSearchToolDefinition, CalculatorToolDefinition } from '@librechat/agents'; import type { ExtendedJsonSchema } from './schema'; import { AskUserQuestionToolDefinition } from '~/agents/hitl/askUserQuestionTool'; +import { OPEN_WEATHER_TOOL_DESCRIPTION } from '~/tools/weather'; import { geminiToolkit } from '~/tools/toolkits/gemini'; import { oaiToolkit } from '~/tools/toolkits/oai'; @@ -354,8 +355,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..09e73f73127 --- /dev/null +++ b/packages/api/src/tools/weather/index.ts @@ -0,0 +1,31 @@ +export type { + CurrentForecastResult, + DailyAggregationResult, + DailyTemperature, + OneCallRecord, + OneCallResponse, + OpenWeatherArgs, + OpenWeatherDeps, + OpenWeatherFetcher, + OverviewResult, + PrecipitationAmount, + WeatherCondition, +} from './types'; +export { + convertDateToUnix, + executeOpenWeather, + getOpenWeatherHelp, + isOpenWeatherPaginationUrl, + OPENWEATHER_API_ORIGIN, + OPEN_WEATHER_TOOL_DESCRIPTION, +} from './service'; +export { + isDailyTemperature, + normalizeCurrentForecast, + normalizeDailyAggregation, + selectDailyRecord, + stripPagination, + synthesizeOverview, + utcDateString, +} from './normalize'; +export { mapUnitsToOpenWeather, 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..f219cfdbc5c --- /dev/null +++ b/packages/api/src/tools/weather/normalize.spec.ts @@ -0,0 +1,128 @@ +import type { OneCallRecord, OneCallResponse } from './types'; +import { + normalizeCurrentForecast, + normalizeDailyAggregation, + selectDailyRecord, + stripPagination, + synthesizeOverview, + 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: 1583280000, + 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('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', () => { + 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?.afternoon).toBe(70); + expect(result.cloud_cover?.afternoon).toBe(12); + }); + + 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('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', () => { + 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.4°C with a real feel of 18.6°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.4°F to 21.9°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..505b6833bcb --- /dev/null +++ b/packages/api/src/tools/weather/normalize.ts @@ -0,0 +1,185 @@ +import type { + CurrentForecastResult, + DailyAggregationResult, + DailyTemperature, + OneCallRecord, + OneCallResponse, + OverviewResult, +} from './types'; + +import { unitSuffix } from './units'; + +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}`; +} + +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 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, +): OneCallRecord | undefined { + const matched = records.find( + (record) => typeof record.dt === 'number' && utcDateString(record.dt) === requestedDate, + ); + return matched ?? records[0]; +} + +function precipitationTotal(record?: OneCallRecord): number | undefined { + if (record == null) { + return undefined; + } + if (typeof record.rain === 'number') { + return record.rain; + } + if (record.rain && typeof record.rain['1h'] === 'number') { + return record.rain['1h']; + } + if (typeof record.snow === 'number') { + return record.snow; + } + if (record.snow && typeof record.snow['1h'] === 'number') { + return record.snow['1h']; + } + return undefined; +} + +export function normalizeDailyAggregation( + response: OneCallResponse, + requestedDate: string, + units: string, +): DailyAggregationResult { + const record = selectDailyRecord(response.data ?? [], requestedDate); + const temp = isDailyTemperature(record?.temp) ? record.temp : undefined; + + return { + lat: response.lat, + lon: response.lon, + tz: response.timezone, + date: requestedDate, + units, + cloud_cover: { afternoon: record?.clouds }, + humidity: { + morning: record?.humidity, + afternoon: record?.humidity, + evening: record?.humidity, + night: record?.humidity, + }, + precipitation: { total: precipitationTotal(record) }, + temperature: { + min: temp?.min, + max: temp?.max, + morning: temp?.morn, + afternoon: temp?.day, + evening: temp?.eve, + night: temp?.night, + }, + pressure: { afternoon: record?.pressure }, + wind: { + max: { + speed: record?.wind_speed, + direction: record?.wind_deg, + }, + }, + }; +} + +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 ${record.feels_like}${suffix}` : ''; + const description = record.weather?.[0]?.description; + const sky = description ? ` The sky is ${description}.` : ''; + return `Currently, the temperature is ${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 ${min}${suffix} to ${max}${suffix}.${sky}`.trim(); + } + if (typeof record.temp.day === 'number') { + return `The daytime temperature is ${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..6fd6367163a --- /dev/null +++ b/packages/api/src/tools/weather/service.spec.ts @@ -0,0 +1,345 @@ +import type { OpenWeatherFetcher } from './types'; +import { + convertDateToUnix, + executeOpenWeather, + getOpenWeatherHelp, + isOpenWeatherPaginationUrl, + OPEN_WEATHER_TOOL_DESCRIPTION, +} from './service'; + +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('describes One Call API 4.0 in the tool string and help payload', () => { + expect(OPEN_WEATHER_TOOL_DESCRIPTION).toContain('One Call API 4.0'); + const help = JSON.parse(getOpenWeatherHelp()) as { title: string }; + expect(help.title).toBe('OpenWeather One Call API 4.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('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, daily, and minutely endpoints', 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 } }], + }); + } + if (url.pathname === '/data/4.0/onecall/timeline/1min') { + return jsonResponse({ data: [{ dt: 1, precipitation: 0 }] }); + } + 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).toContain('/data/4.0/onecall/timeline/1min'); + expect(urls.some((url) => url.includes('/data/3.0/'))).toBe(false); + + 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('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(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('follows safe hourly next links up to the 48-hour cap', async () => { + const { fetch, urls } = createFetch((url) => { + if (!url.pathname.endsWith('/timeline/1h')) { + return jsonResponse({ data: [] }); + } + const start = Number(url.searchParams.get('start') ?? '0'); + if (!url.searchParams.has('start')) { + 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', + }); + } + return jsonResponse({ + data: Array.from({ length: 20 }, (_, i) => ({ dt: start + i, temp: 11 })), + next: `https://api.openweathermap.org/data/4.0/onecall/timeline/1h?start=${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(48); + expect(parsed.hourly[0].dt).toBe(0); + expect(parsed.hourly[47].dt).toBe(47); + expect(urls.filter((url) => url.includes('/timeline/1h'))).toHaveLength(3); + 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('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: 1583280000, + 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', + }, + { apiKey, fetch }, + ), + ) as { temperature: { morning: number; afternoon: number; evening: number } }; + + expect(urls[0]).toContain('/data/4.0/onecall/timeline/1day'); + expect(urls[0]).toContain(`start=${1583280000}`); + expect(parsed.temperature.morning).toBe(283); + expect(parsed.temperature.afternoon).toBe(293); + expect(parsed.temperature.evening).toBe(288); + }); + + 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-07'); + expect(parsed.weather_overview).toContain('2°C'); + expect(parsed.weather_overview).toContain('-2°C'); + expect(parsed.weather_overview.length).toBeGreaterThan(0); + }); + + 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..3f1867bd2aa --- /dev/null +++ b/packages/api/src/tools/weather/service.ts @@ -0,0 +1,619 @@ +import type { + CurrentForecastResult, + DailyAggregationResult, + OneCallRecord, + OneCallResponse, + OpenWeatherArgs, + OpenWeatherDeps, + OverviewResult, +} from './types'; +import { + normalizeCurrentForecast, + normalizeDailyAggregation, + selectDailyRecord, + stripPagination, + synthesizeOverview, + utcDateString, +} from './normalize'; +import { mapUnitsToOpenWeather, roundTemperatures } from './units'; + +export const OPENWEATHER_API_ORIGIN: string = 'https://api.openweathermap.org'; +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.'; + +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 HOURLY_PAGE_LIMIT = 3; +const HOURLY_RECORD_LIMIT = 48; +const HOURLY_PAGE_SIZE = 20; +const DAILY_PAGE_SIZE = 10; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +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 getOpenWeatherHelp(): string { + 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 + timeline/1min', + data_provided: [ + 'Current weather', + 'Minute forecast (1h)', + 'Hourly forecast (up to 48h, paginated)', + 'Daily forecast (up to 10 days)', + ], + 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 (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'], + 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'], + 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)'], + 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.', + 'current_forecast maps 4.0 split endpoints onto the previous current/hourly/daily/minutely contract.', + 'daily_aggregation maps 4.0 daily temp.morn/day/eve/night onto morning/afternoon/evening/night.', + ], + 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): 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 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); +} + +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 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: asStringArray(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 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 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 fetchOpenWeatherJson( + 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 parseOneCallResponse(body); +} + +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)); +} + +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'); + const params = () => + locationParams({ + apiKey: deps.apiKey, + lat: coords.lat, + lon: coords.lon, + units, + lang: args.lang, + }); + + const currentPromise = includeCurrent + ? fetchOpenWeatherJson(buildUrl(CURRENT_PATH, params()), deps.fetch) + : Promise.resolve(undefined); + const hourlyParams = params(); + hourlyParams.set('cnt', String(HOURLY_PAGE_SIZE)); + const hourlyPromise = includeHourly + ? fetchTimeline(HOURLY_PATH, hourlyParams, deps.fetch, { + pages: HOURLY_PAGE_LIMIT, + maxRecords: HOURLY_RECORD_LIMIT, + }) + : Promise.resolve(undefined); + const dailyParams = params(); + dailyParams.set('cnt', String(DAILY_PAGE_SIZE)); + const dailyPromise = includeDaily + ? fetchTimeline(DAILY_PATH, dailyParams, deps.fetch) + : Promise.resolve(undefined); + const minutelyPromise = includeMinutely + ? fetchOpenWeatherJson(buildUrl(MINUTELY_PATH, params()), deps.fetch) + : Promise.resolve(undefined); + + const [current, hourly, daily, minutely] = await Promise.all([ + currentPromise, + hourlyPromise, + dailyPromise, + minutelyPromise, + ]); + + return stringifyResult(normalizeCurrentForecast({ current, hourly, daily, minutely })); +} + +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); + 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(stripPagination(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); + 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)); +} + +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); + params.set('start', String(start)); + params.set('cnt', String(DAILY_PAGE_SIZE)); + const daily = await fetchTimeline(DAILY_PATH, params, deps.fetch); + const record = selectDailyRecord(daily.data ?? [], args.date); + return stringifyResult( + synthesizeOverview({ + daily: record, + date: args.date, + units, + lat: daily.lat ?? coords.lat, + lon: daily.lon ?? coords.lon, + timezone: daily.timezone, + }), + ); + } + + const current = await fetchOpenWeatherJson(buildUrl(CURRENT_PATH, params), deps.fetch); + const record: OneCallRecord | undefined = current.data?.[0]; + const date = + record?.dt != null ? utcDateString(record.dt) : utcDateString(Math.floor(Date.now() / 1000)); + return stringifyResult( + synthesizeOverview({ + current: record, + date, + units, + lat: current.lat ?? coords.lat, + lon: current.lon ?? coords.lon, + timezone: current.timezone, + }), + ); +} + +export async function executeOpenWeather( + args: OpenWeatherArgs, + deps: OpenWeatherDeps, +): Promise { + try { + const { action, city, lat, lon, units } = args; + const owmUnits = mapUnitsToOpenWeather(units); + + if (action === 'help') { + return getOpenWeatherHelp(); + } + + 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 }; + + 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..cada9828956 --- /dev/null +++ b/packages/api/src/tools/weather/types.ts @@ -0,0 +1,129 @@ +export type OpenWeatherFetcher = (url: string) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; +}>; + +export type OpenWeatherDeps = { + apiKey: string; + fetch: OpenWeatherFetcher; +}; + +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 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[]; +}; + +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..62d32a3bac9 --- /dev/null +++ b/packages/api/src/tools/weather/units.spec.ts @@ -0,0 +1,33 @@ +import { mapUnitsToOpenWeather, 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 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..72a1120d024 --- /dev/null +++ b/packages/api/src/tools/weather/units.ts @@ -0,0 +1,65 @@ +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'; +} + +/** + * 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; +} From bf4c0d5bcc1c4709a7b367792ebef9c07c01e9ae Mon Sep 17 00:00:00 2001 From: Lia Date: Mon, 21 Sep 2026 01:26:50 +0000 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Format=20OpenWeathe?= =?UTF-8?q?r=204.0=20Migration=20Files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static checks failed on ESLint (prettier/prettier) and Prettier --check for six formatting violations across the weather service, its specs, and the api-side tool spec. Applied eslint --fix; no logic changes. --- .../tools/structured/specs/openweather.test.js | 4 +++- packages/api/src/tools/weather/normalize.spec.ts | 4 +--- packages/api/src/tools/weather/normalize.ts | 8 ++++++-- packages/api/src/tools/weather/service.spec.ts | 12 ++++++++---- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/api/app/clients/tools/structured/specs/openweather.test.js b/api/app/clients/tools/structured/specs/openweather.test.js index ab494769cb4..634eeba8230 100644 --- a/api/app/clients/tools/structured/specs/openweather.test.js +++ b/api/app/clients/tools/structured/specs/openweather.test.js @@ -43,7 +43,9 @@ function mockOpenWeather({ if (url.includes('/timeline/1day')) { return jsonResponse( daily ?? { - data: [{ dt: 1583280000, temp: { day: 293.15, night: 283.15, morn: 283.15, eve: 288.15 } }], + data: [ + { dt: 1583280000, temp: { day: 293.15, night: 283.15, morn: 283.15, eve: 288.15 } }, + ], }, ); } diff --git a/packages/api/src/tools/weather/normalize.spec.ts b/packages/api/src/tools/weather/normalize.spec.ts index f219cfdbc5c..148882a460f 100644 --- a/packages/api/src/tools/weather/normalize.spec.ts +++ b/packages/api/src/tools/weather/normalize.spec.ts @@ -121,8 +121,6 @@ describe('OpenWeather 4.0 normalizers', () => { units: 'imperial', }); - expect(result.weather_overview).toBe( - 'Temperatures range from 7.4°F to 21.9°F. Few clouds.', - ); + expect(result.weather_overview).toBe('Temperatures range from 7.4°F to 21.9°F. Few clouds.'); }); }); diff --git a/packages/api/src/tools/weather/normalize.ts b/packages/api/src/tools/weather/normalize.ts index 505b6833bcb..5d3fc9cbe4c 100644 --- a/packages/api/src/tools/weather/normalize.ts +++ b/packages/api/src/tools/weather/normalize.ts @@ -136,7 +136,9 @@ function formatOverviewFromCurrent(record: OneCallRecord, units: string): string } const suffix = unitSuffix(units); const feels = - typeof record.feels_like === 'number' ? ` with a real feel of ${record.feels_like}${suffix}` : ''; + typeof record.feels_like === 'number' + ? ` with a real feel of ${record.feels_like}${suffix}` + : ''; const description = record.weather?.[0]?.description; const sky = description ? ` The sky is ${description}.` : ''; return `Currently, the temperature is ${record.temp}${suffix}${feels}.${sky}`.trim(); @@ -170,7 +172,9 @@ export function synthesizeOverview(params: { timezone?: string; }): OverviewResult { const weather_overview = - (params.current != null ? formatOverviewFromCurrent(params.current, params.units) : undefined) ?? + (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.'; diff --git a/packages/api/src/tools/weather/service.spec.ts b/packages/api/src/tools/weather/service.spec.ts index 6fd6367163a..3c564a9aac7 100644 --- a/packages/api/src/tools/weather/service.spec.ts +++ b/packages/api/src/tools/weather/service.spec.ts @@ -305,9 +305,9 @@ describe('executeOpenWeather', () => { 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: '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.'); @@ -337,7 +337,11 @@ describe('executeOpenWeather', () => { }); const result = await executeOpenWeather( - { action: 'current_forecast', city: 'Knoxville, Tennessee', exclude: 'hourly,daily,minutely' }, + { + 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'); From 8a76281e6e73719fb9e21f68513534f716d787b6 Mon Sep 17 00:00:00 2001 From: GokayAI <60583610+gokay-ai@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:46:03 +0000 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=8C=A4=EF=B8=8F=20fix:=20Keep=20One?= =?UTF-8?q?=20Call=203.0=20Keys=20and=20Cut=204.0=20Billed=20Calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenWeather still supports existing 3.0 subscriptions; 4.0 is a separate product. Default remains 4.0 for new keys, with OPENWEATHER_ONECALL_VERSION=3.0 as an explicit escape hatch. On 4.0, current_forecast no longer fetches 1min unless asked, stops hourly pagination at 24h (one page), keeps successful parts via Promise.allSettled, resolves alert IDs, honours tz for local day boundaries, and leaves 4.0-missing daily fields undefined instead of fabricating them. Signed-off-by: GokayAI <60583610+gokay-ai@users.noreply.github.com> --- .env.example | 3 + .../clients/tools/structured/OpenWeather.js | 9 +- .../specs/openWeather.integration.test.js | 2 + .../structured/specs/openweather.test.js | 38 ++ .../api/src/tools/registry/definitions.ts | 14 +- packages/api/src/tools/weather/index.ts | 11 +- .../api/src/tools/weather/normalize.spec.ts | 52 +- packages/api/src/tools/weather/normalize.ts | 167 ++++++- .../api/src/tools/weather/service.spec.ts | 256 +++++++++- packages/api/src/tools/weather/service.ts | 466 +++++++++++++++--- packages/api/src/tools/weather/types.ts | 15 + packages/api/src/tools/weather/units.spec.ts | 9 +- packages/api/src/tools/weather/units.ts | 4 + 13 files changed, 918 insertions(+), 128 deletions(-) diff --git a/.env.example b/.env.example index a85ba4c9234..2c30752df82 100644 --- a/.env.example +++ b/.env.example @@ -1368,6 +1368,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 6770bc83f64..4d356948e77 100644 --- a/api/app/clients/tools/structured/OpenWeather.js +++ b/api/app/clients/tools/structured/OpenWeather.js @@ -5,6 +5,7 @@ const { executeOpenWeather, openWeatherSchema, OPEN_WEATHER_TOOL_DESCRIPTION, + resolveOpenWeatherOneCallVersion, } = require('@librechat/api'); class OpenWeather extends Tool { @@ -33,7 +34,13 @@ class OpenWeather extends Tool { async _call(args) { try { - return await executeOpenWeather(args, { apiKey: this.apiKey, fetch }); + 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 634eeba8230..3169bad43f0 100644 --- a/api/app/clients/tools/structured/specs/openweather.test.js +++ b/api/app/clients/tools/structured/specs/openweather.test.js @@ -102,6 +102,8 @@ describe('OpenWeather Tool', () => { 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); }); @@ -208,6 +210,42 @@ describe('OpenWeather Tool', () => { 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 () => { const result = await tool.call({ action: 'timestamp', diff --git a/packages/api/src/tools/registry/definitions.ts b/packages/api/src/tools/registry/definitions.ts index 0f1dafca202..9cdf65be622 100644 --- a/packages/api/src/tools/registry/definitions.ts +++ b/packages/api/src/tools/registry/definitions.ts @@ -1,7 +1,6 @@ import { WebSearchToolDefinition, CalculatorToolDefinition } from '@librechat/agents'; import type { ExtendedJsonSchema } from './schema'; import { AskUserQuestionToolDefinition } from '~/agents/hitl/askUserQuestionTool'; -import { OPEN_WEATHER_TOOL_DESCRIPTION } from '~/tools/weather'; import { geminiToolkit } from '~/tools/toolkits/gemini'; import { oaiToolkit } from '~/tools/toolkits/oai'; @@ -146,6 +145,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: { @@ -168,7 +174,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', @@ -185,7 +192,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'], diff --git a/packages/api/src/tools/weather/index.ts b/packages/api/src/tools/weather/index.ts index 09e73f73127..aef77cb289e 100644 --- a/packages/api/src/tools/weather/index.ts +++ b/packages/api/src/tools/weather/index.ts @@ -7,25 +7,32 @@ export type { OpenWeatherArgs, OpenWeatherDeps, OpenWeatherFetcher, + OpenWeatherOneCallVersion, OverviewResult, PrecipitationAmount, + WeatherAlert, WeatherCondition, } from './types'; export { convertDateToUnix, + DEFAULT_OPENWEATHER_ONECALL_VERSION, executeOpenWeather, getOpenWeatherHelp, isOpenWeatherPaginationUrl, OPENWEATHER_API_ORIGIN, - OPEN_WEATHER_TOOL_DESCRIPTION, + resolveOpenWeatherOneCallVersion, } from './service'; export { + collectAlertIds, + dateStringInTimeZone, isDailyTemperature, normalizeCurrentForecast, normalizeDailyAggregation, + omitRecordAlerts, selectDailyRecord, stripPagination, synthesizeOverview, + unixAtLocalMidnight, utcDateString, } from './normalize'; -export { mapUnitsToOpenWeather, roundTemperatures, unitSuffix } from './units'; +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 index 148882a460f..ec9391e70de 100644 --- a/packages/api/src/tools/weather/normalize.spec.ts +++ b/packages/api/src/tools/weather/normalize.spec.ts @@ -1,10 +1,14 @@ import type { OneCallRecord, OneCallResponse } from './types'; import { + collectAlertIds, + dateStringInTimeZone, normalizeCurrentForecast, normalizeDailyAggregation, + omitRecordAlerts, selectDailyRecord, stripPagination, synthesizeOverview, + unixAtLocalMidnight, utcDateString, } from './normalize'; @@ -47,6 +51,21 @@ describe('OpenWeather 4.0 normalizers', () => { 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, @@ -64,7 +83,7 @@ describe('OpenWeather 4.0 normalizers', () => { expect(result.minutely?.[0].precipitation).toBe(0); }); - it('maps a 4.0 daily record onto the day-summary temperature fields', () => { + 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'); @@ -78,8 +97,10 @@ describe('OpenWeather 4.0 normalizers', () => { evening: 15.6, night: 8.1, }); - expect(result.humidity?.afternoon).toBe(70); - expect(result.cloud_cover?.afternoon).toBe(12); + expect(result.humidity).toBeUndefined(); + expect(result.cloud_cover).toBeUndefined(); + expect(result.pressure).toBeUndefined(); + expect(result.wind).toBeUndefined(); }); it('selects the daily record whose UTC date matches the request', () => { @@ -90,6 +111,25 @@ describe('OpenWeather 4.0 normalizers', () => { 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('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(); @@ -97,7 +137,7 @@ describe('OpenWeather 4.0 normalizers', () => { expect(stripped.data).toEqual(dailyResponse.data); }); - it('synthesizes an overview from current conditions', () => { + it('synthesizes an overview from current conditions with rounded temperatures', () => { const result = synthesizeOverview({ current: currentResponse.data?.[0], date: '2020-03-04', @@ -108,7 +148,7 @@ describe('OpenWeather 4.0 normalizers', () => { }); expect(result.weather_overview).toBe( - 'Currently, the temperature is 20.4°C with a real feel of 18.6°C. The sky is clear sky.', + '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'); @@ -121,6 +161,6 @@ describe('OpenWeather 4.0 normalizers', () => { units: 'imperial', }); - expect(result.weather_overview).toBe('Temperatures range from 7.4°F to 21.9°F. Few clouds.'); + 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 index 5d3fc9cbe4c..5e343e976b2 100644 --- a/packages/api/src/tools/weather/normalize.ts +++ b/packages/api/src/tools/weather/normalize.ts @@ -7,7 +7,9 @@ import type { OverviewResult, } from './types'; -import { unitSuffix } from './units'; +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'; @@ -21,6 +23,100 @@ export function utcDateString(unixSeconds: number): string { 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, @@ -31,6 +127,29 @@ export function stripPagination(response: OneCallResponse): OneCallResponse { }; } +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; @@ -64,9 +183,11 @@ export function normalizeCurrentForecast(parts: { export function selectDailyRecord( records: OneCallRecord[], requestedDate: string, + timeZone?: string, ): OneCallRecord | undefined { const matched = records.find( - (record) => typeof record.dt === 'number' && utcDateString(record.dt) === requestedDate, + (record) => + typeof record.dt === 'number' && dateStringInTimeZone(record.dt, timeZone) === requestedDate, ); return matched ?? records[0]; } @@ -90,43 +211,39 @@ function precipitationTotal(record?: OneCallRecord): number | undefined { return undefined; } +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 record = selectDailyRecord(response.data ?? [], requestedDate); + 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: response.timezone, + tz: zone, date: requestedDate, units, - cloud_cover: { afternoon: record?.clouds }, - humidity: { - morning: record?.humidity, - afternoon: record?.humidity, - evening: record?.humidity, - night: record?.humidity, - }, - precipitation: { total: precipitationTotal(record) }, - temperature: { + precipitation: definedFields({ total: precipitationTotal(record) }), + temperature: definedFields({ min: temp?.min, max: temp?.max, morning: temp?.morn, afternoon: temp?.day, evening: temp?.eve, night: temp?.night, - }, - pressure: { afternoon: record?.pressure }, - wind: { - max: { - speed: record?.wind_speed, - direction: record?.wind_deg, - }, - }, + }), }; } @@ -137,11 +254,11 @@ function formatOverviewFromCurrent(record: OneCallRecord, units: string): string const suffix = unitSuffix(units); const feels = typeof record.feels_like === 'number' - ? ` with a real feel of ${record.feels_like}${suffix}` + ? ` 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 ${record.temp}${suffix}${feels}.${sky}`.trim(); + return `Currently, the temperature is ${roundDegree(record.temp)}${suffix}${feels}.${sky}`.trim(); } function formatOverviewFromDaily(record: OneCallRecord, units: string): string | undefined { @@ -154,10 +271,10 @@ function formatOverviewFromDaily(record: OneCallRecord, units: string): string | 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 ${min}${suffix} to ${max}${suffix}.${sky}`.trim(); + return `Temperatures range from ${roundDegree(min)}${suffix} to ${roundDegree(max)}${suffix}.${sky}`.trim(); } if (typeof record.temp.day === 'number') { - return `The daytime temperature is ${record.temp.day}${suffix}.${sky}`.trim(); + return `The daytime temperature is ${roundDegree(record.temp.day)}${suffix}.${sky}`.trim(); } return undefined; } diff --git a/packages/api/src/tools/weather/service.spec.ts b/packages/api/src/tools/weather/service.spec.ts index 3c564a9aac7..2fca1ab0fc8 100644 --- a/packages/api/src/tools/weather/service.spec.ts +++ b/packages/api/src/tools/weather/service.spec.ts @@ -4,8 +4,9 @@ import { executeOpenWeather, getOpenWeatherHelp, isOpenWeatherPaginationUrl, - OPEN_WEATHER_TOOL_DESCRIPTION, + resolveOpenWeatherOneCallVersion, } from './service'; +import { OPEN_WEATHER_TOOL_DESCRIPTION } from '../registry/definitions'; function jsonResponse(body: unknown, status = 200) { return { @@ -31,14 +32,35 @@ describe('executeOpenWeather', () => { const apiKey = 'test-api-key'; const geoBody = [{ lat: 35.9606, lon: -83.9207 }]; - it('describes One Call API 4.0 in the tool string and help payload', () => { + 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 }; + 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('24h hourly'))).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.', ); @@ -60,7 +82,7 @@ describe('executeOpenWeather', () => { ); }); - it('geocodes a city and combines 4.0 current, hourly, daily, and minutely endpoints', async () => { + 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); @@ -81,9 +103,6 @@ describe('executeOpenWeather', () => { data: [{ temp: { day: 293.15, night: 283.15 } }], }); } - if (url.pathname === '/data/4.0/onecall/timeline/1min') { - return jsonResponse({ data: [{ dt: 1, precipitation: 0 }] }); - } throw new Error(`unexpected url ${url.toString()}`); }); @@ -109,9 +128,12 @@ describe('executeOpenWeather', () => { 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).toContain('/data/4.0/onecall/timeline/1min'); + 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=24'); + const currentUrl = urls.find((url) => url.includes('/onecall/current')); expect(currentUrl).toContain('units=standard'); expect(currentUrl).toContain('appid=test-api-key'); @@ -145,6 +167,32 @@ describe('executeOpenWeather', () => { 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') { @@ -175,21 +223,14 @@ describe('executeOpenWeather', () => { expect(raw).not.toContain('appid='); }); - it('follows safe hourly next links up to the 48-hour cap', async () => { + it('does not follow hourly next links by default so 24h is one billed page', async () => { const { fetch, urls } = createFetch((url) => { if (!url.pathname.endsWith('/timeline/1h')) { return jsonResponse({ data: [] }); } - const start = Number(url.searchParams.get('start') ?? '0'); - if (!url.searchParams.has('start')) { - 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', - }); - } return jsonResponse({ - data: Array.from({ length: 20 }, (_, i) => ({ dt: start + i, temp: 11 })), - next: `https://api.openweathermap.org/data/4.0/onecall/timeline/1h?start=${start + 20}&appid=SECRET`, + data: Array.from({ length: 24 }, (_, i) => ({ dt: i, temp: 10 })), + next: 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?start=24&appid=SECRET', }); }); @@ -200,10 +241,10 @@ describe('executeOpenWeather', () => { ), ) as { hourly: Array<{ dt: number }> }; - expect(parsed.hourly).toHaveLength(48); + expect(parsed.hourly).toHaveLength(24); expect(parsed.hourly[0].dt).toBe(0); - expect(parsed.hourly[47].dt).toBe(47); - expect(urls.filter((url) => url.includes('/timeline/1h'))).toHaveLength(3); + expect(parsed.hourly[23].dt).toBe(23); + expect(urls.filter((url) => url.includes('/timeline/1h'))).toHaveLength(1); expect(JSON.stringify(parsed)).not.toContain('SECRET'); }); @@ -229,6 +270,107 @@ describe('executeOpenWeather', () => { 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('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') { @@ -236,7 +378,7 @@ describe('executeOpenWeather', () => { timezone: 'America/New_York', data: [ { - dt: 1583280000, + dt: 1583298000, temp: { morn: 283.15, day: 293.15, eve: 288.15, night: 280 }, }, ], @@ -253,16 +395,18 @@ describe('executeOpenWeather', () => { lon: -83.92, date: '2020-03-04', units: 'Kelvin', + tz: 'America/New_York', }, { apiKey, fetch }, ), - ) as { temperature: { morning: number; afternoon: number; evening: number } }; + ) 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=${1583280000}`); + 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('synthesizes overview from current weather when 4.0 has no overview endpoint', async () => { @@ -294,12 +438,74 @@ describe('executeOpenWeather', () => { expect(urls[0]).toContain('/data/4.0/onecall/current'); expect(parsed.units).toBe('metric'); - expect(parsed.date).toBe('2024-01-07'); + 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('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'); diff --git a/packages/api/src/tools/weather/service.ts b/packages/api/src/tools/weather/service.ts index 3f1867bd2aa..f893b308142 100644 --- a/packages/api/src/tools/weather/service.ts +++ b/packages/api/src/tools/weather/service.ts @@ -5,36 +5,39 @@ import type { OneCallResponse, OpenWeatherArgs, OpenWeatherDeps, + OpenWeatherOneCallVersion, OverviewResult, + WeatherAlert, } from './types'; import { + collectAlertIds, + dateStringInTimeZone, normalizeCurrentForecast, normalizeDailyAggregation, + omitRecordAlerts, selectDailyRecord, - stripPagination, synthesizeOverview, - utcDateString, + unixAtLocalMidnight, } from './normalize'; import { mapUnitsToOpenWeather, roundTemperatures } from './units'; export const OPENWEATHER_API_ORIGIN: string = 'https://api.openweathermap.org'; -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 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 HOURLY_PAGE_LIMIT = 3; -const HOURLY_RECORD_LIMIT = 48; -const HOURLY_PAGE_SIZE = 20; +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 = 24; +const HOURLY_PAGE_SIZE = 24; const DAILY_PAGE_SIZE = 10; +const MINUTELY_COUNT = 60; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const ALERT_ID_PATTERN = /^[A-Za-z0-9._-]+$/; const EXCLUDE_PARTS = new Set(['current', 'minutely', 'hourly', 'daily', 'alerts']); const COORDINATE_ACTIONS = new Set([ 'current_forecast', @@ -53,19 +56,119 @@ class OpenWeatherApiError extends Error { } } -export function getOpenWeatherHelp(): string { +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 + timeline/1min', + endpoint: 'data/4.0/onecall/current + timeline/1h + timeline/1day', data_provided: [ 'Current weather', - 'Minute forecast (1h)', - 'Hourly forecast (up to 48h, paginated)', + 'Hourly forecast (24h, 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'], @@ -84,7 +187,7 @@ export function getOpenWeatherHelp(): string { ['lat', 'lon', 'date (YYYY-MM-DD)'], ['city', 'date (YYYY-MM-DD)'], ], - optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang'], + optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang', 'tz'], usage_example: { city: 'Knoxville, Tennessee', date: '2020-03-04', @@ -101,7 +204,7 @@ export function getOpenWeatherHelp(): string { ['lat', 'lon', 'date (YYYY-MM-DD)'], ['city', 'date (YYYY-MM-DD)'], ], - optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang'], + optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang', 'tz'], usage_example: { city: 'Knoxville, Tennessee', date: '2020-03-04', @@ -115,7 +218,7 @@ export function getOpenWeatherHelp(): string { '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)'], + optional_params: ['date (YYYY-MM-DD)', 'units (Celsius/Kelvin/Fahrenheit)', 'tz'], usage_example: { city: 'Knoxville, Tennessee', date: '2024-05-13', @@ -129,8 +232,12 @@ export function getOpenWeatherHelp(): string { 'By default, temperatures are returned in Celsius.', 'You can specify units as Celsius, Kelvin, or Fahrenheit.', 'All temperatures are rounded to the nearest degree.', - 'current_forecast maps 4.0 split endpoints onto the previous current/hourly/daily/minutely contract.', - 'daily_aggregation maps 4.0 daily temp.morn/day/eve/night onto morning/afternoon/evening/night.', + 'One Call 4.0 bills per HTTP call. current_forecast uses current + 24h hourly (one page) + daily. 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 are omitted when 4.0 does not provide them.', + '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)', @@ -145,7 +252,7 @@ export function getOpenWeatherHelp(): string { ); } -export function convertDateToUnix(dateStr: string): number { +export function convertDateToUnix(dateStr: string, timeZone?: string): number { if (!DATE_PATTERN.test(dateStr)) { throw new Error('Invalid date format. Expected YYYY-MM-DD.'); } @@ -157,12 +264,12 @@ export function convertDateToUnix(dateStr: string): number { 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())) { + 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 Math.floor(dateObj.getTime() / 1000); + return unixAtLocalMidnight(year, month, day, timeZone); } export function isOpenWeatherPaginationUrl(url: string): boolean { @@ -190,6 +297,21 @@ 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; @@ -272,7 +394,7 @@ function parseOneCallRecord(value: unknown): OneCallRecord | undefined { rain: parsePrecipitation(value.rain), snow: parsePrecipitation(value.snow), weather: parseWeatherCondition(value.weather), - alerts: asStringArray(value.alerts), + alerts: asIdArray(value.alerts), }; } @@ -297,6 +419,22 @@ function parseOneCallResponse(body: unknown): OneCallResponse { }; } +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: asString(source.description), + tags: asStringArray(source.tags), + }; +} + function readErrorMessage(body: unknown): string { if (isObject(body) && typeof body.message === 'string') { return body.message; @@ -320,6 +458,13 @@ function parseExclude(exclude?: string): Set { ); } +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()}`; } @@ -343,10 +488,10 @@ function locationParams(args: { return params; } -async function fetchOpenWeatherJson( +async function fetchOpenWeatherBody( url: string, fetchImpl: OpenWeatherDeps['fetch'], -): Promise { +): Promise { const response = await fetchImpl(url); const body: unknown = await response.json(); if (!response.ok) { @@ -355,7 +500,14 @@ async function fetchOpenWeatherJson( `OpenWeather API request failed with status ${response.status}: ${readErrorMessage(body)}`, ); } - return parseOneCallResponse(body); + return body; +} + +async function fetchOpenWeatherJson( + url: string, + fetchImpl: OpenWeatherDeps['fetch'], +): Promise { + return parseOneCallResponse(await fetchOpenWeatherBody(url, fetchImpl)); } async function fetchTimeline( @@ -426,6 +578,60 @@ function stringifyResult( 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 (!ALERT_ID_PATTERN.test(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 }, @@ -436,7 +642,8 @@ async function currentForecast( const includeCurrent = !excluded.has('current'); const includeHourly = !excluded.has('hourly'); const includeDaily = !excluded.has('daily'); - const includeMinutely = !excluded.has('minutely'); + const includeMinutely = !excluded.has('minutely') && requestsMinutely(args.exclude); + const includeAlerts = !excluded.has('alerts'); const params = () => locationParams({ apiKey: deps.apiKey, @@ -446,34 +653,99 @@ async function currentForecast( lang: args.lang, }); - const currentPromise = includeCurrent - ? fetchOpenWeatherJson(buildUrl(CURRENT_PATH, params()), deps.fetch) - : Promise.resolve(undefined); - const hourlyParams = params(); - hourlyParams.set('cnt', String(HOURLY_PAGE_SIZE)); - const hourlyPromise = includeHourly - ? fetchTimeline(HOURLY_PATH, hourlyParams, deps.fetch, { + 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, - }) - : Promise.resolve(undefined); - const dailyParams = params(); - dailyParams.set('cnt', String(DAILY_PAGE_SIZE)); - const dailyPromise = includeDaily - ? fetchTimeline(DAILY_PATH, dailyParams, deps.fetch) - : Promise.resolve(undefined); - const minutelyPromise = includeMinutely - ? fetchOpenWeatherJson(buildUrl(MINUTELY_PATH, params()), deps.fetch) - : Promise.resolve(undefined); - - const [current, hourly, daily, minutely] = await Promise.all([ - currentPromise, - hourlyPromise, - dailyPromise, - minutelyPromise, - ]); - - return stringifyResult(normalizeCurrentForecast({ current, hourly, daily, minutely })); + }), + }); + } + 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 errors: 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'; + errors.push(`${name}: ${message}`); + } + } + + if ( + parts.current == null && + parts.hourly == null && + parts.daily == null && + parts.minutely == null + ) { + return `Error: ${errors[0] ?? 'OpenWeather API request failed'}`; + } + + const result = normalizeCurrentForecast(parts); + + 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( @@ -485,7 +757,7 @@ async function timestampForecast( if (!args.date) { return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required."; } - const start = convertDateToUnix(args.date); + const start = convertDateToUnix(args.date, args.tz); const params = locationParams({ apiKey: deps.apiKey, lat: coords.lat, @@ -496,7 +768,7 @@ async function timestampForecast( params.set('start', String(start)); params.set('cnt', String(HOURLY_PAGE_SIZE)); const response = await fetchTimeline(HOURLY_PATH, params, deps.fetch); - return stringifyResult(stripPagination(response)); + return stringifyResult(response); } async function dailyAggregation( @@ -508,7 +780,7 @@ async function dailyAggregation( if (!args.date) { return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.'; } - const start = convertDateToUnix(args.date); + const start = convertDateToUnix(args.date, args.tz); const params = locationParams({ apiKey: deps.apiKey, lat: coords.lat, @@ -519,7 +791,7 @@ async function dailyAggregation( 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)); + return stringifyResult(normalizeDailyAggregation(response, args.date, units, args.tz)); } async function overviewForecast( @@ -537,11 +809,12 @@ async function overviewForecast( }); if (args.date) { - const start = convertDateToUnix(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 record = selectDailyRecord(daily.data ?? [], args.date); + const zone = args.tz ?? daily.timezone; + const record = selectDailyRecord(daily.data ?? [], args.date, zone); return stringifyResult( synthesizeOverview({ daily: record, @@ -549,15 +822,18 @@ async function overviewForecast( units, lat: daily.lat ?? coords.lat, lon: daily.lon ?? coords.lon, - timezone: daily.timezone, + 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 ? utcDateString(record.dt) : utcDateString(Math.floor(Date.now() / 1000)); + record?.dt != null + ? dateStringInTimeZone(record.dt, zone) + : dateStringInTimeZone(Math.floor(Date.now() / 1000), zone); return stringifyResult( synthesizeOverview({ current: record, @@ -565,11 +841,66 @@ async function overviewForecast( units, lat: current.lat ?? coords.lat, lon: current.lon ?? coords.lon, - timezone: current.timezone, + 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, @@ -577,9 +908,10 @@ export async function executeOpenWeather( try { const { action, city, lat, lon, units } = args; const owmUnits = mapUnitsToOpenWeather(units); + const version = resolvedOneCallVersion(deps); if (action === 'help') { - return getOpenWeatherHelp(); + return getOpenWeatherHelp(version); } if (!COORDINATE_ACTIONS.has(action)) { @@ -600,6 +932,10 @@ export async function executeOpenWeather( 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); diff --git a/packages/api/src/tools/weather/types.ts b/packages/api/src/tools/weather/types.ts index cada9828956..8f491e6c3fe 100644 --- a/packages/api/src/tools/weather/types.ts +++ b/packages/api/src/tools/weather/types.ts @@ -4,9 +4,12 @@ export type OpenWeatherFetcher = (url: string) => Promise<{ json: () => Promise; }>; +export type OpenWeatherOneCallVersion = '3.0' | '4.0'; + export type OpenWeatherDeps = { apiKey: string; fetch: OpenWeatherFetcher; + oneCallVersion?: OpenWeatherOneCallVersion; }; export type OpenWeatherArgs = { @@ -41,6 +44,16 @@ 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; @@ -86,6 +99,8 @@ export type CurrentForecastResult = { minutely?: OneCallRecord[]; hourly?: OneCallRecord[]; daily?: OneCallRecord[]; + alerts?: WeatherAlert[]; + errors?: string[]; }; export type DailyAggregationResult = { diff --git a/packages/api/src/tools/weather/units.spec.ts b/packages/api/src/tools/weather/units.spec.ts index 62d32a3bac9..d571847085d 100644 --- a/packages/api/src/tools/weather/units.spec.ts +++ b/packages/api/src/tools/weather/units.spec.ts @@ -1,4 +1,4 @@ -import { mapUnitsToOpenWeather, roundTemperatures, unitSuffix } from './units'; +import { mapUnitsToOpenWeather, roundDegree, roundTemperatures, unitSuffix } from './units'; describe('OpenWeather units', () => { it('maps display units onto OpenWeather units and defaults to metric', () => { @@ -15,6 +15,13 @@ describe('OpenWeather units', () => { 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 }, diff --git a/packages/api/src/tools/weather/units.ts b/packages/api/src/tools/weather/units.ts index 72a1120d024..74e3ed471f4 100644 --- a/packages/api/src/tools/weather/units.ts +++ b/packages/api/src/tools/weather/units.ts @@ -43,6 +43,10 @@ export function unitSuffix(units: string): string { return '°C'; } +export function roundDegree(value: number): number { + return Math.round(value); +} + /** * Recursively round temperature fields in the API response. */ From aabf77660565cae5896ea6f6b73513e36a92e79c Mon Sep 17 00:00:00 2001 From: GokayAI <60583610+gokay-ai@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:49:41 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=8C=A4=EF=B8=8F=20fix:=20Keep=20Whole?= =?UTF-8?q?-Action=204.0=20Failures=20Unprefixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When every current_forecast endpoint fails, return the original OpenWeather status error instead of prefixing it with the first part name. Signed-off-by: GokayAI <60583610+gokay-ai@users.noreply.github.com> --- packages/api/src/tools/weather/service.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/api/src/tools/weather/service.ts b/packages/api/src/tools/weather/service.ts index f893b308142..7a1f2945abc 100644 --- a/packages/api/src/tools/weather/service.ts +++ b/packages/api/src/tools/weather/service.ts @@ -695,7 +695,7 @@ async function currentForecast( daily?: OneCallResponse; minutely?: OneCallResponse; } = {}; - const errors: string[] = []; + const partErrors: Array<{ name: ForecastPartName; message: string }> = []; for (let i = 0; i < settled.length; i++) { const name = requests[i].name; @@ -704,7 +704,7 @@ async function currentForecast( parts[name] = result.value; } else { const message = result.reason instanceof Error ? result.reason.message : 'Unknown error'; - errors.push(`${name}: ${message}`); + partErrors.push({ name, message }); } } @@ -714,10 +714,11 @@ async function currentForecast( parts.daily == null && parts.minutely == null ) { - return `Error: ${errors[0] ?? 'OpenWeather API request failed'}`; + 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( From beef7d8e1f38a2e7026c4313f76a05647495c8dc Mon Sep 17 00:00:00 2001 From: GokayAI <60583610+gokay-ai@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:11:08 +0000 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=8C=A4=EF=B8=8F=20fix:=20Honor=20One?= =?UTF-8?q?=20Call=204.0=20Alert=20Payloads=20and=2020-Record=20Pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize 4.0 multilingual alert descriptions from the official payload, allow opaque alert IDs that contain colons while still rejecting path traversal, request at most 20 hourly records per billed page, and return no-data when a requested daily date is missing instead of records[0]. Signed-off-by: GokayAI <60583610+gokay-ai@users.noreply.github.com> --- .../api/src/tools/weather/normalize.spec.ts | 26 +++ packages/api/src/tools/weather/normalize.ts | 3 +- .../api/src/tools/weather/service.spec.ts | 184 +++++++++++++++++- packages/api/src/tools/weather/service.ts | 61 +++++- 4 files changed, 257 insertions(+), 17 deletions(-) diff --git a/packages/api/src/tools/weather/normalize.spec.ts b/packages/api/src/tools/weather/normalize.spec.ts index ec9391e70de..c9b49a5312e 100644 --- a/packages/api/src/tools/weather/normalize.spec.ts +++ b/packages/api/src/tools/weather/normalize.spec.ts @@ -119,6 +119,32 @@ describe('OpenWeather 4.0 normalizers', () => { 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', diff --git a/packages/api/src/tools/weather/normalize.ts b/packages/api/src/tools/weather/normalize.ts index 5e343e976b2..30c61fd18a9 100644 --- a/packages/api/src/tools/weather/normalize.ts +++ b/packages/api/src/tools/weather/normalize.ts @@ -185,11 +185,10 @@ export function selectDailyRecord( requestedDate: string, timeZone?: string, ): OneCallRecord | undefined { - const matched = records.find( + return records.find( (record) => typeof record.dt === 'number' && dateStringInTimeZone(record.dt, timeZone) === requestedDate, ); - return matched ?? records[0]; } function precipitationTotal(record?: OneCallRecord): number | undefined { diff --git a/packages/api/src/tools/weather/service.spec.ts b/packages/api/src/tools/weather/service.spec.ts index 2fca1ab0fc8..6617efcfe0a 100644 --- a/packages/api/src/tools/weather/service.spec.ts +++ b/packages/api/src/tools/weather/service.spec.ts @@ -49,7 +49,7 @@ describe('executeOpenWeather', () => { }; 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('24h hourly'))).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', () => { @@ -132,7 +132,7 @@ describe('executeOpenWeather', () => { expect(urls.some((url) => url.includes('/data/3.0/'))).toBe(false); const hourlyUrl = urls.find((url) => url.includes('/timeline/1h')); - expect(hourlyUrl).toContain('cnt=24'); + expect(hourlyUrl).toContain('cnt=20'); const currentUrl = urls.find((url) => url.includes('/onecall/current')); expect(currentUrl).toContain('units=standard'); @@ -216,6 +216,7 @@ describe('executeOpenWeather', () => { 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(); @@ -223,14 +224,14 @@ describe('executeOpenWeather', () => { expect(raw).not.toContain('appid='); }); - it('does not follow hourly next links by default so 24h is one billed page', async () => { + 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: 24 }, (_, i) => ({ dt: i, temp: 10 })), - next: 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?start=24&appid=SECRET', + 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', }); }); @@ -241,9 +242,9 @@ describe('executeOpenWeather', () => { ), ) as { hourly: Array<{ dt: number }> }; - expect(parsed.hourly).toHaveLength(24); + expect(parsed.hourly).toHaveLength(20); expect(parsed.hourly[0].dt).toBe(0); - expect(parsed.hourly[23].dt).toBe(23); + expect(parsed.hourly[19].dt).toBe(19); expect(urls.filter((url) => url.includes('/timeline/1h'))).toHaveLength(1); expect(JSON.stringify(parsed)).not.toContain('SECRET'); }); @@ -349,6 +350,104 @@ describe('executeOpenWeather', () => { 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') { @@ -409,6 +508,39 @@ describe('executeOpenWeather', () => { 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('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') { @@ -444,6 +576,44 @@ describe('executeOpenWeather', () => { 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') { diff --git a/packages/api/src/tools/weather/service.ts b/packages/api/src/tools/weather/service.ts index 7a1f2945abc..78fd147c629 100644 --- a/packages/api/src/tools/weather/service.ts +++ b/packages/api/src/tools/weather/service.ts @@ -32,12 +32,12 @@ 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 = 24; -const HOURLY_PAGE_SIZE = 24; +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_ID_PATTERN = /^[A-Za-z0-9._-]+$/; +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', @@ -166,7 +166,7 @@ export function getOpenWeatherHelp(version: OpenWeatherOneCallVersion = '4.0'): endpoint: 'data/4.0/onecall/current + timeline/1h + timeline/1day', data_provided: [ 'Current weather', - 'Hourly forecast (24h, one page)', + 'Hourly forecast (up to 20h, one page)', 'Daily forecast (up to 10 days)', 'Resolved weather alerts when IDs are present', ], @@ -181,7 +181,7 @@ export function getOpenWeatherHelp(version: OpenWeatherOneCallVersion = '4.0'): weather_for_timestamp: { endpoint: 'data/4.0/onecall/timeline/1h', data_provided: [ - 'Hourly weather around a date (history since 1979, forecast up to 48h ahead)', + '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)'], @@ -232,7 +232,7 @@ export function getOpenWeatherHelp(version: OpenWeatherOneCallVersion = '4.0'): '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 + 24h hourly (one page) + daily. Minute precipitation is omitted by default; include +minutely in exclude to fetch it (extra billed call).', + '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 are omitted when 4.0 does not provide them.', @@ -419,6 +419,44 @@ function parseOneCallResponse(body: unknown): OneCallResponse { }; } +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)) { @@ -430,11 +468,18 @@ function parseWeatherAlert(body: unknown, id: string): WeatherAlert | undefined event: asString(source.event), start: asNumber(source.start), end: asNumber(source.end), - description: asString(source.description), + 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; @@ -599,7 +644,7 @@ async function resolveAlerts( const settled = await Promise.allSettled( ids.map(async (id) => { - if (!ALERT_ID_PATTERN.test(id)) { + if (!isSafeAlertId(id)) { throw new Error(`Skipping unsafe alert id`); } const params = new URLSearchParams({ appid: deps.apiKey }); From 81fcd32d46717f7b1c25a03bcaab927ff34b53a6 Mon Sep 17 00:00:00 2001 From: GokayAI <60583610+gokay-ai@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:12:58 +0000 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=8C=A4=EF=B8=8F=20test:=20Match=20Dai?= =?UTF-8?q?ly=20Fixture=20to=20America/New=5FYork=20Local=20Date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The day-summary fixture used UTC midnight, which is the previous local day in America/New_York. With no records[0] fallback, the record must carry the requested local date. Signed-off-by: GokayAI <60583610+gokay-ai@users.noreply.github.com> --- packages/api/src/tools/weather/normalize.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/tools/weather/normalize.spec.ts b/packages/api/src/tools/weather/normalize.spec.ts index c9b49a5312e..df4401ffc28 100644 --- a/packages/api/src/tools/weather/normalize.spec.ts +++ b/packages/api/src/tools/weather/normalize.spec.ts @@ -33,7 +33,7 @@ const dailyResponse: OneCallResponse = { timezone: 'America/New_York', data: [ { - dt: 1583280000, + 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, From 6b5eef080941ef4f2587153f63e98317dc399d6a Mon Sep 17 00:00:00 2001 From: GokayAI <60583610+gokay-ai@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:33:43 +0000 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=8C=A4=EF=B8=8F=20fix:=20Omit=20Hourl?= =?UTF-8?q?y=20Rain=20Rates=20From=20Daily=20Precipitation=20Totals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Call 4.0 1-day rain.1h / snow.1h are mm/h rates, not a daily accumulation. daily_aggregation no longer copies those rates into precipitation.total; the field is omitted unless a documented daily total exists. Signed-off-by: GokayAI <60583610+gokay-ai@users.noreply.github.com> --- .../api/src/tools/weather/normalize.spec.ts | 23 ++++++++++ packages/api/src/tools/weather/normalize.ts | 20 --------- .../api/src/tools/weather/service.spec.ts | 42 +++++++++++++++++++ packages/api/src/tools/weather/service.ts | 2 +- 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/packages/api/src/tools/weather/normalize.spec.ts b/packages/api/src/tools/weather/normalize.spec.ts index df4401ffc28..9ce209c13c7 100644 --- a/packages/api/src/tools/weather/normalize.spec.ts +++ b/packages/api/src/tools/weather/normalize.spec.ts @@ -101,6 +101,29 @@ describe('OpenWeather 4.0 normalizers', () => { 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', () => { diff --git a/packages/api/src/tools/weather/normalize.ts b/packages/api/src/tools/weather/normalize.ts index 30c61fd18a9..b3cfa47990c 100644 --- a/packages/api/src/tools/weather/normalize.ts +++ b/packages/api/src/tools/weather/normalize.ts @@ -191,25 +191,6 @@ export function selectDailyRecord( ); } -function precipitationTotal(record?: OneCallRecord): number | undefined { - if (record == null) { - return undefined; - } - if (typeof record.rain === 'number') { - return record.rain; - } - if (record.rain && typeof record.rain['1h'] === 'number') { - return record.rain['1h']; - } - if (typeof record.snow === 'number') { - return record.snow; - } - if (record.snow && typeof record.snow['1h'] === 'number') { - return record.snow['1h']; - } - return undefined; -} - function definedFields(value: T): T | undefined { const entries = Object.entries(value).filter(([, field]) => field !== undefined); if (entries.length === 0) { @@ -234,7 +215,6 @@ export function normalizeDailyAggregation( tz: zone, date: requestedDate, units, - precipitation: definedFields({ total: precipitationTotal(record) }), temperature: definedFields({ min: temp?.min, max: temp?.max, diff --git a/packages/api/src/tools/weather/service.spec.ts b/packages/api/src/tools/weather/service.spec.ts index 6617efcfe0a..d574458932a 100644 --- a/packages/api/src/tools/weather/service.spec.ts +++ b/packages/api/src/tools/weather/service.spec.ts @@ -541,6 +541,48 @@ describe('executeOpenWeather', () => { 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') { diff --git a/packages/api/src/tools/weather/service.ts b/packages/api/src/tools/weather/service.ts index 78fd147c629..3d4affcb061 100644 --- a/packages/api/src/tools/weather/service.ts +++ b/packages/api/src/tools/weather/service.ts @@ -235,7 +235,7 @@ export function getOpenWeatherHelp(version: OpenWeatherOneCallVersion = '4.0'): '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 are omitted when 4.0 does not provide them.', + '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.', ], From b9e3581d20b4501cd030c0308b94cdfff5435c40 Mon Sep 17 00:00:00 2001 From: GokayAI <60583610+gokay-ai@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:36:13 +0000 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Format=20Daily=20Pr?= =?UTF-8?q?ecipitation=20Omission=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GokayAI <60583610+gokay-ai@users.noreply.github.com> --- .../api/src/tools/weather/service.spec.ts | 67 +++++++++---------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/packages/api/src/tools/weather/service.spec.ts b/packages/api/src/tools/weather/service.spec.ts index d574458932a..16bbd782fec 100644 --- a/packages/api/src/tools/weather/service.spec.ts +++ b/packages/api/src/tools/weather/service.spec.ts @@ -546,42 +546,39 @@ describe('executeOpenWeather', () => { ['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()}`); - }); + ] 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 } }; - 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(); - }, - ); + 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) => {