import { defineSource } from '@jaspers-ai/sdk' import { z } from 'zod' // The OECD's SDMX API. No key. Cross-country indicators, addressed by a dataflow and a key, which // is how SDMX works and is not friendly; the shortcuts below are the ones worth having by name and // each was checked against the live service. const HOST = 'sdmx.oecd.org' /** dataflow and key, for the series worth asking for by name. `%COUNTRY%` is filled in. */ const KNOWN: Record = { cpi: { dataflow: 'OECD.SDD.TPS,DSD_PRICES@DF_PRICES_ALL,1.0', key: '%COUNTRY%.M.N.CPI.PA._T.N.GY', what: 'Consumer prices, year on year, monthly', }, production: { dataflow: 'OECD.SDD.STES,DSD_KEI@DF_KEI,4.0', key: '%COUNTRY%.M.PRVM.IX.....', what: 'Industrial production, index, monthly', }, } interface Sdmx { dataSets?: { series: Record }> }[] structure?: { dimensions: { observation: { values: { id: string }[] }[] } } } export const series = defineSource({ description: `A cross-country OECD series. Ask by name for one of: ${Object.entries(KNOWN) .map(([name, one]) => `${name} (${one.what})`) .join(', ')}. Anything else needs the dataflow and key SDMX addresses it by, which the OECD data explorer shows for a series.`, hosts: [HOST], input: z.object({ series: z.string().default('cpi').describe(`One of ${Object.keys(KNOWN).join(', ')}, or "custom" with dataflow and key.`), country: z.string().default('USA').describe('An ISO three letter code, like USA, DEU, JPN.'), dataflow: z.string().optional().describe('For a series this does not know by name.'), key: z.string().optional(), points: z.number().int().min(1).max(2000).default(60), }), async run({ series: wanted, country, dataflow, key, points }, ctx) { const known = KNOWN[wanted.trim().toLowerCase()] const flow = known?.dataflow ?? dataflow const path = (known?.key ?? key)?.replace('%COUNTRY%', country.trim().toUpperCase()) if (!flow || !path) { throw new Error(`No OECD series called "${wanted}". Ask for ${Object.keys(KNOWN).join(' or ')}, or give dataflow and key.`) } const url = `https://${HOST}/public/rest/data/${flow}/${path}?lastNObservations=${points}&format=jsondata` // The service returns the odd 500 on a request it answers a moment later, so one retry. let response = await ctx.fetch(url) if (response.status >= 500) { await new Promise((resolve) => setTimeout(resolve, 700)) response = await ctx.fetch(url) } if (!response.ok) { const said = await response.text().catch(() => '') throw new Error(`The OECD answered ${response.status}. ${said.slice(0, 120)}`) } // The OECD wraps its payload in `data`, where the ECB puts the same fields at the top level. const body = (await response.json()) as { data?: Sdmx } & Partial const payload = body.data ?? (body as Sdmx) const dates = payload.structure?.dimensions.observation[0]?.values.map((one) => one.id) ?? [] const found = Object.values(payload.dataSets?.[0]?.series ?? {})[0] if (!found) throw new Error('The OECD has nothing for that country on that series.') const rows = Object.entries(found.observations) .map(([at, values]) => ({ country: country.toUpperCase(), date: dates[Number(at)] ?? at, value: values[0] })) .filter((one) => typeof one.value === 'number') .sort((a, b) => a.date.localeCompare(b.date)) return { series: wanted, what: known?.what ?? 'custom', rows } }, })