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.
- package/dist/cjs/cache.d.ts +9 -0
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +37 -7
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/engine.d.ts +15 -0
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +111 -21
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/generator.d.ts.map +1 -1
- package/dist/cjs/generator.js +28 -6
- package/dist/cjs/generator.js.map +1 -1
- package/dist/cjs/index.d.ts +2 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/learning.d.ts +7 -0
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +35 -8
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +38 -1
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +106 -23
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.js +27 -9
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts +2 -2
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +66 -26
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +821 -68
- package/dist/cjs/schema.d.ts.map +1 -1
- package/dist/cjs/schema.js +61 -12
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +147 -9
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/cache.d.ts +9 -0
- package/dist/esm/cache.js +37 -7
- package/dist/esm/engine.d.ts +15 -0
- package/dist/esm/engine.js +112 -22
- package/dist/esm/generator.js +28 -6
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/learning.d.ts +7 -0
- package/dist/esm/learning.js +35 -8
- package/dist/esm/matcher.d.ts +38 -1
- package/dist/esm/matcher.js +104 -23
- package/dist/esm/parser.js +27 -9
- package/dist/esm/resolver.d.ts +2 -2
- package/dist/esm/resolver.js +66 -26
- package/dist/esm/schema.d.ts +821 -68
- package/dist/esm/schema.js +61 -12
- package/dist/esm/types.d.ts +147 -9
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/matcher.js
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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(
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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,
|
package/dist/esm/parser.js
CHANGED
|
@@ -31,7 +31,16 @@ async function loadSpec(source) {
|
|
|
31
31
|
return parseSpecText(text, source);
|
|
32
32
|
}
|
|
33
33
|
// Local file
|
|
34
|
-
const
|
|
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
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
263
|
-
`
|
|
264
|
-
|
|
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, '');
|
package/dist/esm/resolver.d.ts
CHANGED
|
@@ -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:
|
|
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>;
|
package/dist/esm/resolver.js
CHANGED
|
@@ -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];
|
|
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
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
};
|