capman 0.6.0 → 0.6.1

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.
Files changed (57) hide show
  1. package/dist/cjs/cache.d.ts +9 -0
  2. package/dist/cjs/cache.d.ts.map +1 -1
  3. package/dist/cjs/cache.js +37 -7
  4. package/dist/cjs/cache.js.map +1 -1
  5. package/dist/cjs/engine.d.ts +15 -0
  6. package/dist/cjs/engine.d.ts.map +1 -1
  7. package/dist/cjs/engine.js +111 -21
  8. package/dist/cjs/engine.js.map +1 -1
  9. package/dist/cjs/generator.d.ts.map +1 -1
  10. package/dist/cjs/generator.js +28 -6
  11. package/dist/cjs/generator.js.map +1 -1
  12. package/dist/cjs/index.d.ts +2 -1
  13. package/dist/cjs/index.d.ts.map +1 -1
  14. package/dist/cjs/index.js +3 -1
  15. package/dist/cjs/index.js.map +1 -1
  16. package/dist/cjs/learning.d.ts +7 -0
  17. package/dist/cjs/learning.d.ts.map +1 -1
  18. package/dist/cjs/learning.js +35 -8
  19. package/dist/cjs/learning.js.map +1 -1
  20. package/dist/cjs/matcher.d.ts +38 -1
  21. package/dist/cjs/matcher.d.ts.map +1 -1
  22. package/dist/cjs/matcher.js +106 -23
  23. package/dist/cjs/matcher.js.map +1 -1
  24. package/dist/cjs/parser.js +27 -9
  25. package/dist/cjs/parser.js.map +1 -1
  26. package/dist/cjs/resolver.d.ts +2 -2
  27. package/dist/cjs/resolver.d.ts.map +1 -1
  28. package/dist/cjs/resolver.js +66 -26
  29. package/dist/cjs/resolver.js.map +1 -1
  30. package/dist/cjs/schema.d.ts +821 -68
  31. package/dist/cjs/schema.d.ts.map +1 -1
  32. package/dist/cjs/schema.js +61 -12
  33. package/dist/cjs/schema.js.map +1 -1
  34. package/dist/cjs/types.d.ts +147 -9
  35. package/dist/cjs/types.d.ts.map +1 -1
  36. package/dist/cjs/version.d.ts +1 -1
  37. package/dist/cjs/version.js +1 -1
  38. package/dist/esm/cache.d.ts +9 -0
  39. package/dist/esm/cache.js +37 -7
  40. package/dist/esm/engine.d.ts +15 -0
  41. package/dist/esm/engine.js +112 -22
  42. package/dist/esm/generator.js +28 -6
  43. package/dist/esm/index.d.ts +2 -1
  44. package/dist/esm/index.js +1 -0
  45. package/dist/esm/learning.d.ts +7 -0
  46. package/dist/esm/learning.js +35 -8
  47. package/dist/esm/matcher.d.ts +38 -1
  48. package/dist/esm/matcher.js +104 -23
  49. package/dist/esm/parser.js +27 -9
  50. package/dist/esm/resolver.d.ts +2 -2
  51. package/dist/esm/resolver.js +66 -26
  52. package/dist/esm/schema.d.ts +821 -68
  53. package/dist/esm/schema.js +61 -12
  54. package/dist/esm/types.d.ts +147 -9
  55. package/dist/esm/version.d.ts +1 -1
  56. package/dist/esm/version.js +1 -1
  57. package/package.json +1 -1
@@ -136,15 +136,19 @@ export function tokenize(text) {
136
136
  export function buildBM25Index(capabilities) {
137
137
  const N = capabilities.length;
138
138
  if (N === 0)
139
- return { df: {}, avgdl: { examples: 0, description: 0, name: 0 }, N: 0, bigrams: {}, };
139
+ return { df: {}, avgdl: { examples: 0, description: 0, name: 0 }, N: 0, bigrams: {}, capTokens: {}, };
140
140
  const df = {};
141
141
  let totalExLen = 0;
142
142
  let totalDescLen = 0;
143
143
  let totalNameLen = 0;
144
+ // Pre-compute token arrays for every capability in a single pass.
145
+ // scoreCapability() reads from capTokens instead of re-tokenizing on every call.
146
+ const capTokens = {};
144
147
  for (const cap of capabilities) {
145
148
  const exTokens = tokenize((cap.examples ?? []).join(' '));
146
149
  const descTokens = tokenize(cap.description);
147
150
  const nameTokens = tokenize(cap.name);
151
+ capTokens[cap.id] = { examples: exTokens, description: descTokens, name: nameTokens };
148
152
  totalExLen += exTokens.length;
149
153
  totalDescLen += descTokens.length;
150
154
  totalNameLen += nameTokens.length;
@@ -177,6 +181,7 @@ export function buildBM25Index(capabilities) {
177
181
  },
178
182
  N,
179
183
  bigrams,
184
+ capTokens,
180
185
  };
181
186
  }
182
187
  /**
@@ -187,9 +192,17 @@ export function buildBM25Index(capabilities) {
187
192
  export function scoreCapability(qWordSet, cap, index, k1 = 1.5, b = 0.75) {
188
193
  if (index.N === 0)
189
194
  return 0;
190
- const score = bm25Field(qWordSet, tokenize((cap.examples ?? []).join(' ')), index, 'examples', k1, b) * 0.6
191
- + bm25Field(qWordSet, tokenize(cap.description), index, 'description', k1, b) * 0.3
192
- + bm25Field(qWordSet, tokenize(cap.name), index, 'name', k1, b) * 0.1;
195
+ // Use pre-computed token arrays from the index avoids re-tokenizing
196
+ // capability text on every call. Falls back to live tokenization only when
197
+ // scoreCapability() is called outside CapmanEngine (e.g. unit tests that
198
+ // build a BM25Index manually without capTokens populated).
199
+ const tokens = index.capTokens[cap.id];
200
+ const exTokens = tokens?.examples ?? tokenize((cap.examples ?? []).join(' '));
201
+ const descTokens = tokens?.description ?? tokenize(cap.description);
202
+ const nameTokens = tokens?.name ?? tokenize(cap.name);
203
+ const score = bm25Field(qWordSet, exTokens, index, 'examples', k1, b) * 0.6
204
+ + bm25Field(qWordSet, descTokens, index, 'description', k1, b) * 0.3
205
+ + bm25Field(qWordSet, nameTokens, index, 'name', k1, b) * 0.1;
193
206
  return score;
194
207
  }
195
208
  function bm25Field(queryTerms, fieldTokens, index, field, k1, b) {
@@ -224,6 +237,32 @@ export function extractBigrams(tokens) {
224
237
  }
225
238
  return bigrams;
226
239
  }
240
+ /**
241
+ * Returns a sub-manifest containing only capabilities that match ALL provided tags.
242
+ * Capabilities without tags are excluded when tags filter is active.
243
+ * Enables token-efficient LLM prompts for large manifests:
244
+ *
245
+ * @example
246
+ * // Only send order-related capabilities to LLM
247
+ * const orderManifest = filterByTags(manifest, ['orders'])
248
+ * const result = await matchWithLLM(query, orderManifest, { llm })
249
+ *
250
+ * @example
251
+ * // Match by any of multiple tags (union) — call filterByTags per tag and merge
252
+ * const ordersOrPayments = [
253
+ * ...filterByTags(manifest, ['orders']).capabilities,
254
+ * ...filterByTags(manifest, ['payments']).capabilities,
255
+ * ]
256
+ */
257
+ export function filterByTags(manifest, tags) {
258
+ if (tags.length === 0)
259
+ return manifest;
260
+ const tagSet = new Set(tags);
261
+ return {
262
+ ...manifest,
263
+ capabilities: manifest.capabilities.filter(cap => cap.tags?.length && tags.every(t => cap.tags.includes(t))),
264
+ };
265
+ }
227
266
  /**
228
267
  * Returns a fixed bonus in normalized points (0–15), applied after BM25 normalization.
229
268
  * 5 points per matching bigram, saturates at 3 bigrams (15 points).
@@ -252,13 +291,18 @@ export function resolverToIntent(cap) {
252
291
  /**
253
292
  * Strips characters that could break LLM prompt structure from
254
293
  * capability field values before injection into the system prompt.
255
- * Removes control characters, newlines, and delimiter-like sequences.
294
+ * Removes control characters, newlines, delimiter sequences, and braces
295
+ * anywhere in the string (not just at line starts) to resist prompt injection
296
+ * from third-party OpenAPI spec content ingested via parseOpenAPI().
256
297
  */
257
298
  export function sanitizeForPrompt(value, maxLen) {
258
299
  return value
259
- .replace(/[\r\n\t]/g, ' ') // newlines → space
300
+ .replace(/[\r\n\t]/g, ' ') // newlines/tabs → space
260
301
  .replace(/---+/g, '—') // horizontal rules → em dash
261
- .replace(/^\s*[{}\[\]]/gm, ' ') // leading braces/brackets → space
302
+ .replace(/[{}\[\]]/g, ' ') // all braces/brackets anywhere → space (was: leading only)
303
+ .split(' ') // per-word cap — limits injection payload per token
304
+ .map(w => w.slice(0, 200)) // no single token longer than 200 chars
305
+ .join(' ')
262
306
  .replace(/\s+/g, ' ') // collapse whitespace
263
307
  .trim()
264
308
  .slice(0, maxLen);
@@ -290,11 +334,28 @@ export function extractParams(query, cap) {
290
334
  result[param.name] = null;
291
335
  continue;
292
336
  }
293
- // ── Pattern extraction (highest priority) ─────────────────────────────
337
+ // ── Type-implied pattern extraction ───────────────────────────────────
338
+ // param.type implies a TYPE_PATTERNS match — no need to set pattern explicitly
339
+ if (param.type && !param.pattern) {
340
+ // Map param types that have direct regex equivalents
341
+ const typeToPattern = {
342
+ email: TYPE_PATTERNS.email,
343
+ date: TYPE_PATTERNS.date,
344
+ url: TYPE_PATTERNS.url,
345
+ };
346
+ const impliedPattern = typeToPattern[param.type];
347
+ if (impliedPattern) {
348
+ const match = query.match(impliedPattern);
349
+ if (match) {
350
+ result[param.name] = match[0];
351
+ continue;
352
+ }
353
+ }
354
+ }
355
+ // ── Explicit pattern extraction (highest priority when set) ───────────
294
356
  if (param.pattern) {
295
357
  const namedPattern = TYPE_PATTERNS[param.pattern];
296
358
  if (namedPattern) {
297
- // Named type pattern — match regex directly against full query
298
359
  const match = query.match(namedPattern);
299
360
  if (match) {
300
361
  result[param.name] = match[0];
@@ -302,7 +363,6 @@ export function extractParams(query, cap) {
302
363
  }
303
364
  }
304
365
  else if (param.pattern.includes(`{${param.name}}`)) {
305
- // Example template — positional extraction
306
366
  const extracted = extractFromTemplate(query, param.pattern, param.name);
307
367
  if (extracted) {
308
368
  result[param.name] = extracted;
@@ -363,10 +423,36 @@ export function extractParams(query, cap) {
363
423
  extracted = candidate;
364
424
  }
365
425
  }
426
+ // ── Enum validation ───────────────────────────────────────────────────
427
+ if (extracted !== null && param.type === 'enum' && param.enum?.length) {
428
+ if (!param.enum.includes(extracted)) {
429
+ // Extracted value not in allowed list — treat as not found
430
+ extracted = null;
431
+ }
432
+ }
366
433
  result[param.name] = extracted;
367
434
  }
368
435
  return result;
369
436
  }
437
+ /**
438
+ * Calibrates a BM25 normalization ceiling from the manifest.
439
+ * Scores each capability against all of its own examples and returns the maximum.
440
+ * Call once at manifest load time — O(capabilities × examples).
441
+ */
442
+ export function calibrateCeiling(capabilities, bm25Index, k1, b) {
443
+ let max = 0;
444
+ for (const cap of capabilities) {
445
+ if (!cap.examples?.length)
446
+ continue;
447
+ for (const example of cap.examples) {
448
+ const selfWords = new Set(tokenize(example));
449
+ const raw = scoreCapability(selfWords, cap, bm25Index, k1, b);
450
+ if (raw > max)
451
+ max = raw;
452
+ }
453
+ }
454
+ return max > 0 ? max : 100;
455
+ }
370
456
  export function match(query, manifest, options = {}) {
371
457
  if (!query?.trim()) {
372
458
  logger.warn('Empty query received');
@@ -441,18 +527,7 @@ export function match(query, manifest, options = {}) {
441
527
  const k1 = options.bm25K1 ?? 1.5;
442
528
  const b = options.bm25B ?? 0.75;
443
529
  // Calibrate ceiling — max self-score for normalization
444
- const ceiling = options.bm25Ceiling ?? (() => {
445
- let max = 0;
446
- for (const cap of manifest.capabilities) {
447
- if (!cap.examples?.length)
448
- continue;
449
- const selfWords = new Set(tokenize(cap.examples[0]));
450
- const raw = scoreCapability(selfWords, cap, bm25Index, k1, b);
451
- if (raw > max)
452
- max = raw;
453
- }
454
- return max > 0 ? max : 100;
455
- })();
530
+ const ceiling = options.bm25Ceiling ?? calibrateCeiling(manifest.capabilities, bm25Index, k1, b);
456
531
  const allScores = [];
457
532
  for (const cap of manifest.capabilities) {
458
533
  const rawBM25 = scoreCapability(qWordSet, cap, bm25Index, k1, b);
@@ -575,7 +650,13 @@ ${JSON.stringify({ user_query: query })}
575
650
  // Build full candidate list — all capabilities scored, LLM winner marked as matched.
576
651
  // This aligns the shape with keyword match results and allows the learning boost
577
652
  // to surface alternatives if the LLM made a wrong call.
578
- const llmConfidence = effectivelyOOS ? 0 : parsed.confidence;
653
+ // Clamp and round confidence — LLM may return values outside 0–100 with
654
+ // misconfigured models or prompt drift. Unclamped values corrupt learning
655
+ // weights (weight = confidence/100 can exceed 1.0) and verdict margins.
656
+ // disambiguateLLM() already does this; apply the same treatment here.
657
+ const llmConfidence = effectivelyOOS
658
+ ? 0
659
+ : Math.min(100, Math.max(0, Math.round(parsed.confidence)));
579
660
  const allCandidates = manifest.capabilities.map(c => ({
580
661
  capabilityId: c.id,
581
662
  score: c.id === capability?.id ? llmConfidence : 0,
@@ -31,7 +31,16 @@ async function loadSpec(source) {
31
31
  return parseSpecText(text, source);
32
32
  }
33
33
  // Local file
34
- const resolved = path.resolve(process.cwd(), source);
34
+ const cwd = process.cwd();
35
+ const resolved = path.resolve(cwd, source);
36
+ // Guard against path traversal — same check used by FileCache and FileLearningStore.
37
+ // Prevents parseOpenAPI('../../etc/passwd') from reading arbitrary files when
38
+ // the source argument comes from user input (CLI args, UI, CI scripts).
39
+ const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
40
+ if (!resolved.startsWith(allowedPrefix)) {
41
+ throw new Error(`Spec path "${source}" resolves outside the working directory.\n` +
42
+ `Resolved: ${resolved}\nAllowed: ${cwd}`);
43
+ }
35
44
  if (!fs.existsSync(resolved)) {
36
45
  throw new Error(`Spec file not found: ${resolved}`);
37
46
  }
@@ -101,11 +110,20 @@ function convertSpec(spec) {
101
110
  warnings.push(`Skipped ${method} ${urlPath} — no useful info to generate capability`);
102
111
  continue;
103
112
  }
104
- // Check for duplicate IDs
105
- const existing = capabilities.find(c => c.id === result.id);
106
- if (existing) {
107
- result.id = `${result.id}_${method.toLowerCase()}`;
108
- warnings.push(`Duplicate ID resolved: ${result.id}`);
113
+ // De-conflict duplicate IDs — loop until the candidate ID is unique.
114
+ // A single find() check is insufficient: if two operations both produce
115
+ // `get_user`, the second becomes `get_user_get`. A third `get_user` would
116
+ // then collide with `get_user_get` only when it also uses GET — the general
117
+ // multi-collision case is only caught by looping.
118
+ let candidateId = result.id;
119
+ let dedupeCount = 0;
120
+ while (capabilities.find(c => c.id === candidateId)) {
121
+ dedupeCount++;
122
+ candidateId = `${result.id}_${method.toLowerCase()}${dedupeCount > 1 ? `_${dedupeCount}` : ''}`;
123
+ }
124
+ if (candidateId !== result.id) {
125
+ warnings.push(`Duplicate ID resolved: ${result.id} → ${candidateId}`);
126
+ result.id = candidateId;
109
127
  }
110
128
  capabilities.push(result);
111
129
  }
@@ -259,9 +277,9 @@ function extractBaseUrl(spec) {
259
277
  const base = spec.basePath ?? '';
260
278
  return `${scheme}://${spec.host}${base}`.replace(/\/$/, '');
261
279
  }
262
- logger.warn(`No server URL found in spec — using placeholder "https://api.your-app.com". ` +
263
- `Set baseUrl manually in the generated config before use.`);
264
- return 'https://api.your-app.com';
280
+ throw new Error(`No server URL found in OpenAPI spec — cannot determine base URL.\n` +
281
+ `Add a "servers" entry (OpenAPI 3.x) or "host" + "basePath" (Swagger 2.x), ` +
282
+ `or set baseUrl manually in capman.config.js after generating.`);
265
283
  }
266
284
  function sanitizeAppName(title) {
267
285
  return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
@@ -1,4 +1,4 @@
1
- import type { MatchResult, ResolveResult } from './types';
1
+ import type { MatchResult, ResolveResult, Capability } from './types';
2
2
  export interface AuthContext {
3
3
  /** Whether the current request is authenticated */
4
4
  isAuthenticated: boolean;
@@ -25,5 +25,5 @@ export interface ResolveOptions {
25
25
  */
26
26
  retryAllMethods?: boolean;
27
27
  }
28
- export declare function checkPrivacy(capability: import('./types').Capability, auth?: AuthContext): string | null;
28
+ export declare function checkPrivacy(capability: Capability, auth?: AuthContext): string | null;
29
29
  export declare function resolve(matchResult: MatchResult, params?: Record<string, unknown>, options?: ResolveOptions): Promise<ResolveResult>;
@@ -68,13 +68,13 @@ export async function resolve(matchResult, params = {}, options = {}) {
68
68
  .map(p => p.name));
69
69
  switch (resolver.type) {
70
70
  case 'api':
71
- return await resolveApi(resolver, enrichedParams, options, sessionParamNames);
71
+ return await resolveApi(resolver, enrichedParams, options, sessionParamNames, capability.errors ?? []);
72
72
  case 'nav':
73
73
  return resolveNav(resolver, enrichedParams);
74
74
  case 'hybrid': {
75
75
  logger.debug('Hybrid resolver — running API and nav in parallel');
76
76
  const [apiResult, navResult] = await Promise.all([
77
- resolveApi(resolver.api, enrichedParams, options, sessionParamNames),
77
+ resolveApi(resolver.api, enrichedParams, options, sessionParamNames, capability.errors ?? []),
78
78
  Promise.resolve(resolveNav(resolver.nav, enrichedParams)),
79
79
  ]);
80
80
  return {
@@ -116,23 +116,27 @@ export async function resolve(matchResult, params = {}, options = {}) {
116
116
  * Full partial success reporting (partialSuccess, completedCalls, failedCalls)
117
117
  * is planned for a future version.
118
118
  */
119
- async function resolveApi(resolver, params, options, sessionParamNames = new Set()) {
119
+ async function resolveApi(resolver, params, options, sessionParamNames = new Set(), capabilityErrors = []) {
120
120
  const startTime = Date.now();
121
121
  const retries = options.retries ?? 0;
122
122
  const timeoutMs = options.timeoutMs ?? 5000;
123
+ // Map url → endpoint metadata for idempotency and Idempotency-Key injection
124
+ const endpointMeta = new Map();
123
125
  const apiCalls = resolver.endpoints.map(endpoint => {
124
- // Build per-endpoint params — only inject session params if this
125
- // specific endpoint has the placeholder. Prevents userId leaking
126
- // as ?user_id=xyz on endpoints that don't use it in their path.
127
126
  const endpointParams = { ...params };
128
127
  for (const name of sessionParamNames) {
129
128
  if (!endpoint.path.includes(`{${name}}`)) {
130
- delete endpointParams[name]; // strip session param — not in this endpoint's path
129
+ delete endpointParams[name];
131
130
  }
132
131
  }
132
+ const url = buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams, sessionParamNames);
133
+ endpointMeta.set(url, {
134
+ idempotent: endpoint.idempotent,
135
+ idempotencyKey: endpoint.idempotencyKey,
136
+ });
133
137
  return {
134
138
  method: endpoint.method,
135
- url: buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams, sessionParamNames),
139
+ url,
136
140
  params: Object.fromEntries(Object.entries(endpointParams).filter(([, v]) => v !== null && v !== undefined)),
137
141
  };
138
142
  });
@@ -151,23 +155,42 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
151
155
  // Only retry safe/idempotent methods — retrying POST/PUT/PATCH/DELETE
152
156
  // can cause duplicate side effects (e.g. duplicate orders, double charges).
153
157
  async function fetchWithRetry(call) {
154
- const effectiveRetries = (options.retryAllMethods || SAFE_METHODS.has(call.method))
155
- ? retries
156
- : 0;
157
- let lastErr;
158
+ const meta = endpointMeta.get(call.url);
159
+ // Explicit idempotent flag overrides method-based default
160
+ const isIdempotent = meta?.idempotent !== undefined
161
+ ? meta.idempotent
162
+ : SAFE_METHODS.has(call.method);
163
+ const effectiveRetries = (options.retryAllMethods || isIdempotent) ? retries : 0;
164
+ let lastErr = new Error('fetchWithRetry: exhausted all attempts without result');
158
165
  for (let attempt = 0; attempt <= effectiveRetries; attempt++) {
159
166
  const controller = new AbortController();
160
167
  const timer = setTimeout(() => controller.abort(), timeoutMs);
161
168
  try {
169
+ // Inject Idempotency-Key header when configured
170
+ const idempotencyHeaders = {};
171
+ if (meta?.idempotencyKey) {
172
+ const keyValue = call.params[meta.idempotencyKey];
173
+ if (keyValue !== null && keyValue !== undefined) {
174
+ idempotencyHeaders['Idempotency-Key'] = String(keyValue);
175
+ }
176
+ }
162
177
  const res = await fetchFn(call.url, {
163
178
  method: call.method,
164
- headers: options.headers ?? {},
179
+ headers: { ...options.headers ?? {}, ...idempotencyHeaders },
165
180
  signal: controller.signal,
166
181
  body: ['POST', 'PUT', 'PATCH'].includes(call.method)
167
182
  ? JSON.stringify(Object.fromEntries(Object.entries(call.params).filter(([, v]) => v !== null && v !== undefined)))
168
183
  : undefined,
169
184
  });
170
185
  clearTimeout(timer);
186
+ // Throw on retryable 5xx — fetch() resolves (doesn't throw) on HTTP errors,
187
+ // so without this check a 503 is returned immediately with no retry.
188
+ // 4xx errors are not retried — they are client errors that won't change.
189
+ if (res.status >= 500 && attempt < effectiveRetries) {
190
+ lastErr = new Error(`HTTP ${res.status}`);
191
+ logger.warn(`Server error ${res.status} (attempt ${attempt + 1}/${effectiveRetries + 1}) — retrying`);
192
+ continue;
193
+ }
171
194
  return res;
172
195
  }
173
196
  catch (err) {
@@ -184,32 +207,49 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
184
207
  }
185
208
  throw lastErr;
186
209
  }
210
+ let enrichedCalls = apiCalls.map(c => ({ ...c }));
187
211
  try {
188
- const responses = await Promise.all(apiCalls.map(c => fetchWithRetry(c)));
189
- const failedIdx = responses.findIndex(r => !r.ok);
190
- if (failedIdx !== -1) {
191
- const failed = responses[failedIdx];
192
- return {
193
- success: false, resolverType: 'api', apiCalls,
194
- durationMs: Date.now() - startTime,
195
- error: `API request failed: ${failed.status} ${failed.statusText}`,
196
- };
197
- }
198
- const enrichedCalls = await Promise.all(responses.map(async (res, i) => {
212
+ const settled = await Promise.allSettled(apiCalls.map(c => fetchWithRetry(c)));
213
+ enrichedCalls = await Promise.all(settled.map(async (result, i) => {
214
+ if (result.status === 'rejected') {
215
+ const reason = result.reason;
216
+ logger.warn(`Endpoint ${apiCalls[i].method} ${apiCalls[i].url} failed: ${reason}`);
217
+ return {
218
+ ...apiCalls[i],
219
+ status: 0,
220
+ error: reason instanceof Error ? reason.message : String(reason),
221
+ };
222
+ }
223
+ const res = result.value;
199
224
  let data = undefined;
200
225
  try {
201
226
  const text = await res.text();
202
227
  data = text ? JSON.parse(text) : undefined;
203
228
  }
204
- catch { /* non-JSON response */ }
229
+ catch { /* non-JSON response body */ }
205
230
  return { ...apiCalls[i], status: res.status, data };
206
231
  }));
232
+ const failedCall = enrichedCalls.find(c => typeof c.status === 'number' && (c.status === 0 || c.status >= 400));
233
+ if (failedCall) {
234
+ const matchedError = capabilityErrors.find(e => e.httpStatus === failedCall.status);
235
+ const statusLabel = failedCall.status === 0 ? 'network failure' : String(failedCall.status);
236
+ return {
237
+ success: false,
238
+ resolverType: 'api',
239
+ apiCalls: enrichedCalls,
240
+ durationMs: Date.now() - startTime,
241
+ error: matchedError
242
+ ? `${matchedError.code}: ${matchedError.description}`
243
+ : `API request failed: ${statusLabel} on ${failedCall.method} ${failedCall.url}`,
244
+ matchedError,
245
+ };
246
+ }
207
247
  logger.debug(`API calls completed in ${Date.now() - startTime}ms`);
208
248
  return { success: true, resolverType: 'api', apiCalls: enrichedCalls, durationMs: Date.now() - startTime };
209
249
  }
210
250
  catch (err) {
211
251
  return {
212
- success: false, resolverType: 'api', apiCalls,
252
+ success: false, resolverType: 'api', apiCalls: enrichedCalls,
213
253
  durationMs: Date.now() - startTime,
214
254
  error: err instanceof Error ? err.message : String(err),
215
255
  };