ninerouter-mcp 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Country name <-> ISO 3166-1 alpha-2 conversion for the `country` search filter:
3
+ * Tavily wants a country name, Serper and friends want an ISO code.
4
+ */
5
+ /**
6
+ * Lookup key. Case, accents and separators are folded away, so "Côte d'Ivoire"
7
+ * and "cote divoire" resolve to the same country.
8
+ */
9
+ function normalizeKey(value) {
10
+ return value
11
+ .normalize('NFD')
12
+ .replace(/[\u0300-\u036f]/g, '')
13
+ .toLowerCase()
14
+ .replace(/&/g, ' and ')
15
+ .replace(/[^a-z0-9]+/g, ' ')
16
+ .trim();
17
+ }
18
+ /**
19
+ * ISO 3166-1 alpha-2 code -> country name, spelled the way Tavily lists them.
20
+ * Tavily accepts only these names, so the list stays as long as its enum.
21
+ */
22
+ const COUNTRY_LIST = [
23
+ ['af', 'afghanistan'],
24
+ ['al', 'albania'],
25
+ ['dz', 'algeria'],
26
+ ['ad', 'andorra'],
27
+ ['ao', 'angola'],
28
+ ['ar', 'argentina'],
29
+ ['am', 'armenia'],
30
+ ['au', 'australia'],
31
+ ['at', 'austria'],
32
+ ['az', 'azerbaijan'],
33
+ ['bs', 'bahamas'],
34
+ ['bh', 'bahrain'],
35
+ ['bd', 'bangladesh'],
36
+ ['bb', 'barbados'],
37
+ ['by', 'belarus'],
38
+ ['be', 'belgium'],
39
+ ['bz', 'belize'],
40
+ ['bj', 'benize'],
41
+ ['bt', 'bhutan'],
42
+ ['bo', 'bolivia'],
43
+ ['ba', 'bosnia and herzegovina'],
44
+ ['bw', 'botswana'],
45
+ ['br', 'brazil'],
46
+ ['bn', 'brunei'],
47
+ ['bg', 'bulgaria'],
48
+ ['bf', 'burkina faso'],
49
+ ['bi', 'burundi'],
50
+ ['kh', 'cambodia'],
51
+ ['cm', 'cameroon'],
52
+ ['ca', 'canada'],
53
+ ['cv', 'cape verde'],
54
+ ['cf', 'central african republic'],
55
+ ['td', 'chad'],
56
+ ['cl', 'chile'],
57
+ ['cn', 'china'],
58
+ ['co', 'colombia'],
59
+ ['km', 'comoros'],
60
+ ['cg', 'congo'],
61
+ ['cr', 'costa rica'],
62
+ ['hr', 'croatia'],
63
+ ['cu', 'cuba'],
64
+ ['cy', 'cyprus'],
65
+ ['cz', 'czech republic'],
66
+ ['dk', 'denmark'],
67
+ ['dj', 'djibouti'],
68
+ ['do', 'dominican republic'],
69
+ ['ec', 'ecuador'],
70
+ ['eg', 'egypt'],
71
+ ['sv', 'el salvador'],
72
+ ['gq', 'equatorial guinea'],
73
+ ['er', 'eritrea'],
74
+ ['ee', 'estonia'],
75
+ ['et', 'ethiopia'],
76
+ ['fj', 'fiji'],
77
+ ['fi', 'finland'],
78
+ ['fr', 'france'],
79
+ ['ga', 'gabon'],
80
+ ['gm', 'gambia'],
81
+ ['ge', 'georgia'],
82
+ ['de', 'germany'],
83
+ ['gh', 'ghana'],
84
+ ['gr', 'greece'],
85
+ ['gt', 'guatemala'],
86
+ ['gn', 'guinea'],
87
+ ['ht', 'haiti'],
88
+ ['hn', 'honduras'],
89
+ ['hu', 'hungary'],
90
+ ['is', 'iceland'],
91
+ ['in', 'india'],
92
+ ['id', 'indonesia'],
93
+ ['ir', 'iran'],
94
+ ['iq', 'iraq'],
95
+ ['ie', 'ireland'],
96
+ ['il', 'israel'],
97
+ ['it', 'italy'],
98
+ ['jm', 'jamaica'],
99
+ ['jp', 'japan'],
100
+ ['jo', 'jordan'],
101
+ ['kz', 'kazakhstan'],
102
+ ['ke', 'kenya'],
103
+ ['kw', 'kuwait'],
104
+ ['kg', 'kyrgyzstan'],
105
+ ['lv', 'latvia'],
106
+ ['lb', 'lebanon'],
107
+ ['ls', 'lesotho'],
108
+ ['lr', 'liberia'],
109
+ ['ly', 'libya'],
110
+ ['li', 'liechtenstein'],
111
+ ['lt', 'lithuania'],
112
+ ['lu', 'luxembourg'],
113
+ ['mg', 'madagascar'],
114
+ ['mw', 'malawi'],
115
+ ['my', 'malaysia'],
116
+ ['mv', 'maldives'],
117
+ ['ml', 'mali'],
118
+ ['mt', 'malta'],
119
+ ['mr', 'mauritania'],
120
+ ['mu', 'mauritius'],
121
+ ['mx', 'mexico'],
122
+ ['md', 'moldova'],
123
+ ['mc', 'monaco'],
124
+ ['mn', 'mongolia'],
125
+ ['me', 'montenegro'],
126
+ ['ma', 'morocco'],
127
+ ['mz', 'mozambique'],
128
+ ['mm', 'myanmar'],
129
+ ['na', 'namibia'],
130
+ ['np', 'nepal'],
131
+ ['nl', 'netherlands'],
132
+ ['nz', 'new zealand'],
133
+ ['ni', 'nicaragua'],
134
+ ['ne', 'niger'],
135
+ ['ng', 'nigeria'],
136
+ ['kp', 'north korea'],
137
+ ['mk', 'north macedonia'],
138
+ ['no', 'norway'],
139
+ ['om', 'oman'],
140
+ ['pk', 'pakistan'],
141
+ ['pa', 'panama'],
142
+ ['pg', 'papua new guinea'],
143
+ ['py', 'paraguay'],
144
+ ['pe', 'peru'],
145
+ ['ph', 'philippines'],
146
+ ['pl', 'poland'],
147
+ ['pt', 'portugal'],
148
+ ['qa', 'qatar'],
149
+ ['ro', 'romania'],
150
+ ['ru', 'russia'],
151
+ ['rw', 'rwanda'],
152
+ ['sa', 'saudi arabia'],
153
+ ['sn', 'senegal'],
154
+ ['rs', 'serbia'],
155
+ ['sg', 'singapore'],
156
+ ['sk', 'slovakia'],
157
+ ['si', 'slovenia'],
158
+ ['so', 'somalia'],
159
+ ['za', 'south africa'],
160
+ ['kr', 'south korea'],
161
+ ['ss', 'south sudan'],
162
+ ['es', 'spain'],
163
+ ['lk', 'sri lanka'],
164
+ ['sd', 'sudan'],
165
+ ['se', 'sweden'],
166
+ ['ch', 'switzerland'],
167
+ ['sy', 'syria'],
168
+ ['tw', 'taiwan'],
169
+ ['tj', 'tajikistan'],
170
+ ['tz', 'tanzania'],
171
+ ['th', 'thailand'],
172
+ ['tg', 'togo'],
173
+ ['tt', 'trinidad and tobago'],
174
+ ['tn', 'tunisia'],
175
+ ['tr', 'turkey'],
176
+ ['tm', 'turkmenistan'],
177
+ ['ug', 'uganda'],
178
+ ['ua', 'ukraine'],
179
+ ['ae', 'united arab emirates'],
180
+ ['gb', 'united kingdom'],
181
+ ['us', 'united states'],
182
+ ['uy', 'uruguay'],
183
+ ['uz', 'uzbekistan'],
184
+ ['ve', 'venezuela'],
185
+ ['vn', 'vietnam'],
186
+ ['ye', 'yemen'],
187
+ ['zm', 'zambia'],
188
+ ['zw', 'zimbabwe'],
189
+ ];
190
+ /**
191
+ * Names outside the list above, plus the spellings agents actually produce.
192
+ * They resolve to a code but have no country-name form.
193
+ */
194
+ const COUNTRY_ALIASES = {
195
+ 'aland islands': 'ax',
196
+ 'american samoa': 'as',
197
+ anguilla: 'ai',
198
+ antarctica: 'aq',
199
+ 'antigua and barbuda': 'ag',
200
+ aruba: 'aw',
201
+ bermuda: 'bm',
202
+ bosnia: 'ba',
203
+ 'bouvet island': 'bv',
204
+ 'british indian ocean territory': 'io',
205
+ 'british virgin islands': 'vg',
206
+ burma: 'mm',
207
+ 'cape verde islands': 'cv',
208
+ 'caribbean netherlands': 'bq',
209
+ 'cayman islands': 'ky',
210
+ 'christmas island': 'cx',
211
+ 'cocos keeling islands': 'cc',
212
+ 'congo brazzaville': 'cg',
213
+ 'congo kinshasa': 'cd',
214
+ 'cook islands': 'ck',
215
+ 'cote d ivoire': 'ci',
216
+ 'cote divoire': 'ci',
217
+ curacao: 'cw',
218
+ czechia: 'cz',
219
+ 'democratic republic of the congo': 'cd',
220
+ dominica: 'dm',
221
+ 'dr congo': 'cd',
222
+ 'east timor': 'tl',
223
+ england: 'gb',
224
+ eswatini: 'sz',
225
+ 'falkland islands': 'fk',
226
+ 'faroe islands': 'fo',
227
+ 'french guiana': 'gf',
228
+ 'french polynesia': 'pf',
229
+ 'french southern territories': 'tf',
230
+ gibraltar: 'gi',
231
+ 'great britain': 'gb',
232
+ greenland: 'gl',
233
+ grenada: 'gd',
234
+ guadeloupe: 'gp',
235
+ guam: 'gu',
236
+ guernsey: 'gg',
237
+ 'guinea bissau': 'gw',
238
+ guyana: 'gy',
239
+ 'heard and mcdonald islands': 'hm',
240
+ holland: 'nl',
241
+ 'hong kong': 'hk',
242
+ 'hong kong sar china': 'hk',
243
+ 'isle of man': 'im',
244
+ 'ivory coast': 'ci',
245
+ jersey: 'je',
246
+ kiribati: 'ki',
247
+ korea: 'kr',
248
+ laos: 'la',
249
+ 'macao sar china': 'mo',
250
+ macedonia: 'mk',
251
+ 'marshall islands': 'mh',
252
+ martinique: 'mq',
253
+ mayotte: 'yt',
254
+ micronesia: 'fm',
255
+ montserrat: 'ms',
256
+ 'myanmar burma': 'mm',
257
+ nauru: 'nr',
258
+ 'new caledonia': 'nc',
259
+ niue: 'nu',
260
+ 'norfolk island': 'nf',
261
+ 'northern ireland': 'gb',
262
+ 'northern mariana islands': 'mp',
263
+ palau: 'pw',
264
+ palestine: 'ps',
265
+ 'palestinian territories': 'ps',
266
+ 'pitcairn islands': 'pn',
267
+ 'puerto rico': 'pr',
268
+ 'republic of korea': 'kr',
269
+ reunion: 're',
270
+ 'russian federation': 'ru',
271
+ samoa: 'ws',
272
+ 'san marino': 'sm',
273
+ 'sao tome and principe': 'st',
274
+ scotland: 'gb',
275
+ seychelles: 'sc',
276
+ 'sierra leone': 'sl',
277
+ 'sint maarten': 'sx',
278
+ 'slovak republic': 'sk',
279
+ 'solomon islands': 'sb',
280
+ 'south georgia and south sandwich islands': 'gs',
281
+ 'south korean republic': 'kr',
282
+ 'sri lanka': 'lk',
283
+ 'st barthelemy': 'bl',
284
+ 'st helena': 'sh',
285
+ 'st kitts and nevis': 'kn',
286
+ 'st lucia': 'lc',
287
+ 'st martin': 'mf',
288
+ 'st pierre and miquelon': 'pm',
289
+ 'st vincent and grenadines': 'vc',
290
+ suriname: 'sr',
291
+ 'svalbard and jan mayen': 'sj',
292
+ swaziland: 'sz',
293
+ 'timor leste': 'tl',
294
+ tokelau: 'tk',
295
+ tonga: 'to',
296
+ 'trinidad tobago': 'tt',
297
+ 'turks and caicos islands': 'tc',
298
+ tuvalu: 'tv',
299
+ 'u s': 'us',
300
+ 'u k': 'gb',
301
+ 'u s outlying islands': 'um',
302
+ 'u s virgin islands': 'vi',
303
+ 'u.s.a.': 'us',
304
+ uae: 'ae',
305
+ 'united states of america': 'us',
306
+ usa: 'us',
307
+ vanuatu: 'vu',
308
+ vatican: 'va',
309
+ 'vatican city': 'va',
310
+ 'wallis and futuna': 'wf',
311
+ wales: 'gb',
312
+ 'western sahara': 'eh',
313
+ };
314
+ const CODE_TO_NAME = new Map(COUNTRY_LIST);
315
+ const NAME_TO_CODE = new Map();
316
+ for (const [code, name] of COUNTRY_LIST) {
317
+ NAME_TO_CODE.set(normalizeKey(name), code);
318
+ }
319
+ for (const [alias, code] of Object.entries(COUNTRY_ALIASES)) {
320
+ NAME_TO_CODE.set(normalizeKey(alias), code);
321
+ }
322
+ /** True for anything shaped like an ISO 3166-1 alpha-2 code, known or not. */
323
+ export function isCountryCode(value) {
324
+ return /^[a-z]{2}$/.test(value.trim().toLowerCase());
325
+ }
326
+ /**
327
+ * Country name or ISO code -> ISO 3166-1 alpha-2 code.
328
+ * Returns undefined when the value names no known country.
329
+ */
330
+ export function toCountryCode(value) {
331
+ const key = normalizeKey(value);
332
+ if (!key) {
333
+ return undefined;
334
+ }
335
+ return NAME_TO_CODE.get(key) ?? (isCountryCode(key) ? key : undefined);
336
+ }
337
+ /**
338
+ * Country name or ISO code -> the country name Tavily accepts.
339
+ * Returns undefined when no name form is known.
340
+ */
341
+ export function toCountryName(value) {
342
+ const key = normalizeKey(value);
343
+ if (!key) {
344
+ return undefined;
345
+ }
346
+ const direct = CODE_TO_NAME.get(key);
347
+ if (direct) {
348
+ return direct;
349
+ }
350
+ const code = NAME_TO_CODE.get(key);
351
+ return code ? CODE_TO_NAME.get(code) : undefined;
352
+ }
@@ -0,0 +1,106 @@
1
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ /** How long a download URL stays valid. */
3
+ export const DOWNLOAD_TTL_MS = 15 * 60 * 1000;
4
+ /** Strip characters that would break out of the Content-Disposition header. */
5
+ function safeFileName(name) {
6
+ return name.replace(/["\\\r\n]/g, '');
7
+ }
8
+ /**
9
+ * Signed, time-limited download URLs. The server holds a random secret for its
10
+ * lifetime; a link carries the session, the file id and an expiry, and the HMAC
11
+ * over all three is the only thing that authorizes the download. That lets a
12
+ * browser fetch the file without the MCP session, while the secret stays local.
13
+ *
14
+ * A session id is assigned when the transport does not report one, so every
15
+ * link is bound to a session either way.
16
+ */
17
+ export class DownloadStore {
18
+ secret;
19
+ ttlMs;
20
+ entries = new Map();
21
+ constructor(secret = randomBytes(32), ttlMs = DOWNLOAD_TTL_MS) {
22
+ this.secret = secret;
23
+ this.ttlMs = ttlMs;
24
+ }
25
+ key(sessionId, id) {
26
+ return `${sessionId}:${id}`;
27
+ }
28
+ sign(sessionId, id, expiresAt) {
29
+ return createHmac('sha256', this.secret)
30
+ .update(`${sessionId}:${id}:${expiresAt}`)
31
+ .digest('base64url');
32
+ }
33
+ mint(file, now = Date.now()) {
34
+ const sessionId = file.sessionId ?? randomUUID();
35
+ const id = randomUUID();
36
+ const expiresAt = now + this.ttlMs;
37
+ this.entries.set(this.key(sessionId, id), {
38
+ path: file.path,
39
+ contentType: file.contentType,
40
+ fileName: safeFileName(file.fileName),
41
+ sessionId,
42
+ expiresAt,
43
+ });
44
+ return { sessionId, id, expiresAt, token: this.sign(sessionId, id, expiresAt) };
45
+ }
46
+ /** The entry a valid token points at, or undefined when it is forged or expired. */
47
+ verify(sessionId, id, expiresAt, token, now = Date.now()) {
48
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
49
+ return undefined;
50
+ }
51
+ const expected = Buffer.from(this.sign(sessionId, id, expiresAt));
52
+ const given = Buffer.from(token);
53
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) {
54
+ return undefined;
55
+ }
56
+ const entry = this.entries.get(this.key(sessionId, id));
57
+ if (!entry || entry.expiresAt !== expiresAt) {
58
+ return undefined;
59
+ }
60
+ return entry;
61
+ }
62
+ prune(now = Date.now()) {
63
+ for (const [key, entry] of this.entries) {
64
+ if (entry.expiresAt <= now) {
65
+ this.entries.delete(key);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ /**
71
+ * Build an exposer that mints links against the origin the client reached the
72
+ * server on. `publicUrl` wins when set (a deployment behind a proxy), then the
73
+ * request's own host, then `fallbackOrigin` when the transport reports none.
74
+ */
75
+ export function createUrlExposer(store, filesPath, publicUrl, fallbackOrigin = '') {
76
+ return (file) => {
77
+ const { sessionId, id, expiresAt, token } = store.mint(file);
78
+ const origin = publicUrl ?? (originFromRequest(file.request) || fallbackOrigin);
79
+ return {
80
+ location: `${origin}${filesPath}/${sessionId}/${id}?exp=${expiresAt}&sig=${token}`,
81
+ kind: 'url',
82
+ expiresAt,
83
+ };
84
+ };
85
+ }
86
+ /** Prefer the proxy headers when present, then the Host the client used. */
87
+ function originFromRequest(request) {
88
+ if (!request) {
89
+ return '';
90
+ }
91
+ let parsed;
92
+ try {
93
+ parsed = new URL(request.url);
94
+ }
95
+ catch {
96
+ return '';
97
+ }
98
+ const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim();
99
+ const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();
100
+ const host = forwardedHost || request.headers.get('host') || parsed.host;
101
+ if (!host) {
102
+ return '';
103
+ }
104
+ const proto = forwardedProto || parsed.protocol.replace(':', '') || 'http';
105
+ return `${proto}://${host}`;
106
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * How generated media reaches the caller. Under stdio the MCP server shares the
3
+ * client's filesystem, so a path is enough. Under HTTP the server may sit on
4
+ * another host, where a path points at a filesystem the client cannot open and
5
+ * the bytes need a URL instead.
6
+ */
7
+ export const LOCAL_RUNTIME = { remote: false };
8
+ /**
9
+ * `auto` inlines the bytes under stdio and hands back a URL over HTTP. A remote
10
+ * server without an exposer falls back to inlining, because a path it cannot
11
+ * serve is worse than a large response.
12
+ */
13
+ export function resolveMediaDelivery(mode, runtime) {
14
+ if (mode === 'path') {
15
+ return { inline: false, url: false };
16
+ }
17
+ if (mode === 'inline') {
18
+ return { inline: true, url: false };
19
+ }
20
+ return runtime.remote && runtime.expose
21
+ ? { inline: false, url: true }
22
+ : { inline: true, url: false };
23
+ }
@@ -0,0 +1,220 @@
1
+ import { requestGetJson } from '../ninerouter-client.js';
2
+ import { isCountryCode, toCountryCode, toCountryName } from './countries.js';
3
+ /** Catalog alias -> provider id, for the providers whose alias differs. */
4
+ const PROVIDER_ALIASES = {
5
+ brave: 'brave-search',
6
+ gpse: 'google-pse',
7
+ pplx: 'perplexity',
8
+ pw: 'perplexity-web',
9
+ };
10
+ // Mirrors 9Router's per-provider request builders; keep in step with them.
11
+ const CAPABILITIES = {
12
+ tavily: {
13
+ country: true,
14
+ countryFormat: 'name',
15
+ language: false,
16
+ timeRange: false,
17
+ domainFilter: true,
18
+ },
19
+ exa: {
20
+ country: false,
21
+ countryFormat: 'any',
22
+ language: false,
23
+ timeRange: false,
24
+ domainFilter: true,
25
+ },
26
+ serper: {
27
+ country: true,
28
+ countryFormat: 'iso2-lower',
29
+ language: true,
30
+ timeRange: false,
31
+ domainFilter: false,
32
+ },
33
+ 'brave-search': {
34
+ country: true,
35
+ countryFormat: 'iso2-upper',
36
+ language: true,
37
+ timeRange: false,
38
+ domainFilter: false,
39
+ },
40
+ // Searches through a chat model, which forwards the query and the result
41
+ // count only.
42
+ perplexity: {
43
+ country: false,
44
+ countryFormat: 'any',
45
+ language: false,
46
+ timeRange: false,
47
+ domainFilter: false,
48
+ },
49
+ 'google-pse': {
50
+ country: true,
51
+ countryFormat: 'iso2-lower',
52
+ language: true,
53
+ timeRange: true,
54
+ domainFilter: false,
55
+ },
56
+ linkup: {
57
+ country: false,
58
+ countryFormat: 'any',
59
+ language: false,
60
+ timeRange: true,
61
+ domainFilter: true,
62
+ },
63
+ searchapi: {
64
+ country: true,
65
+ countryFormat: 'iso2-lower',
66
+ language: true,
67
+ timeRange: false,
68
+ domainFilter: false,
69
+ },
70
+ youcom: {
71
+ country: true,
72
+ countryFormat: 'iso2-upper',
73
+ language: true,
74
+ timeRange: true,
75
+ domainFilter: true,
76
+ },
77
+ searxng: {
78
+ country: false,
79
+ countryFormat: 'any',
80
+ language: true,
81
+ timeRange: true,
82
+ domainFilter: false,
83
+ },
84
+ xquik: {
85
+ country: false,
86
+ countryFormat: 'any',
87
+ language: true,
88
+ timeRange: false,
89
+ domainFilter: false,
90
+ },
91
+ tinyfish: {
92
+ country: true,
93
+ countryFormat: 'iso2-upper',
94
+ language: true,
95
+ timeRange: false,
96
+ domainFilter: true,
97
+ },
98
+ // Ollama forwards the country and language to its own API, which documents
99
+ // only the query and the result count, and ignores both.
100
+ 'ollama-search': {
101
+ country: false,
102
+ countryFormat: 'any',
103
+ language: false,
104
+ timeRange: false,
105
+ domainFilter: false,
106
+ },
107
+ glm: {
108
+ country: false,
109
+ countryFormat: 'any',
110
+ language: false,
111
+ timeRange: false,
112
+ domainFilter: false,
113
+ },
114
+ };
115
+ /** Providers that read each field, used to explain a dropped filter. */
116
+ const SUPPORTED_BY = {
117
+ country: 'tavily, serper, brave-search, google-pse, searchapi, youcom, tinyfish',
118
+ language: 'serper, brave-search, google-pse, searchapi, youcom, searxng, xquik, tinyfish',
119
+ timeRange: 'google-pse, linkup, youcom, searxng',
120
+ domainFilter: 'tavily, exa, linkup, youcom, tinyfish',
121
+ };
122
+ /**
123
+ * Capabilities of a provider, or undefined for an unknown one (a combo, or a
124
+ * provider added to 9Router later). Unknown providers get every field as-is.
125
+ */
126
+ export function lookupWebCapabilities(modelId) {
127
+ const key = modelId.trim().toLowerCase();
128
+ const providerId = PROVIDER_ALIASES[key] ?? key;
129
+ return CAPABILITIES[providerId];
130
+ }
131
+ /**
132
+ * Rewrite a country filter into the format the provider expects. A value the
133
+ * provider cannot accept is dropped, not forwarded: Tavily rejects the whole
134
+ * request when its country enum does not match.
135
+ */
136
+ export function convertCountry(value, format, provider) {
137
+ const raw = value.trim();
138
+ if (format === 'any') {
139
+ return { action: 'send', value: raw };
140
+ }
141
+ if (format === 'name') {
142
+ const name = toCountryName(raw);
143
+ if (!name) {
144
+ return {
145
+ action: 'drop',
146
+ warning: `country "${raw}" has no name that ${provider} accepts, so the filter was dropped.`,
147
+ };
148
+ }
149
+ if (name === raw) {
150
+ return { action: 'send', value: name };
151
+ }
152
+ return {
153
+ action: 'send',
154
+ value: name,
155
+ warning: `country "${raw}" went out as "${name}" because ${provider} expects a country name.`,
156
+ };
157
+ }
158
+ const code = toCountryCode(raw);
159
+ if (!code) {
160
+ return {
161
+ action: 'drop',
162
+ warning: `country "${raw}" is not a known country, so the filter was dropped.`,
163
+ };
164
+ }
165
+ const out = format === 'iso2-upper' ? code.toUpperCase() : code;
166
+ if (out === raw) {
167
+ return { action: 'send', value: out };
168
+ }
169
+ if (format === 'iso2-upper') {
170
+ return {
171
+ action: 'send',
172
+ value: out,
173
+ warning: `country "${raw}" went out as "${out}" because ${provider} expects an uppercase ISO 3166-1 alpha-2 code.`,
174
+ };
175
+ }
176
+ if (isCountryCode(raw)) {
177
+ // The provider lowercases the code itself, so the case difference never
178
+ // reaches it.
179
+ return { action: 'send', value: out };
180
+ }
181
+ return {
182
+ action: 'send',
183
+ value: out,
184
+ warning: `country "${raw}" went out as "${out}" because ${provider} expects an ISO 3166-1 alpha-2 code.`,
185
+ };
186
+ }
187
+ /** Message for a filter the provider never reads. */
188
+ export function unsupportedFilterWarning(parameter, provider) {
189
+ return `${provider} does not read ${parameter}, so the filter was dropped. Only ${SUPPORTED_BY[parameter]} read it.`;
190
+ }
191
+ const INFO_CACHE_TTL_MS = 60_000;
192
+ const infoCache = new Map();
193
+ /**
194
+ * Per-provider metadata from 9Router's catalog. Providers without a dedicated
195
+ * search API have no entry, and the caller gets no extra detail.
196
+ */
197
+ export async function fetchProviderInfo(config, modelId, action) {
198
+ const key = `${action}:${modelId}`;
199
+ const cached = infoCache.get(key);
200
+ if (cached && cached.expires > Date.now()) {
201
+ return cached.info;
202
+ }
203
+ let info;
204
+ try {
205
+ const payload = await requestGetJson(config, '/v1/models/info', {
206
+ id: `${modelId}/${action}`,
207
+ });
208
+ info = {
209
+ searchTypes: Array.isArray(payload.searchTypes)
210
+ ? payload.searchTypes.filter((value) => typeof value === 'string')
211
+ : undefined,
212
+ maxResults: typeof payload.maxResults === 'number' ? payload.maxResults : undefined,
213
+ };
214
+ }
215
+ catch {
216
+ info = undefined;
217
+ }
218
+ infoCache.set(key, { expires: Date.now() + INFO_CACHE_TTL_MS, info });
219
+ return info;
220
+ }