Skip to content

Commit 7a62ca7

Browse files
feat: enhance OpenAPI tool loading with base URL resolution and improved body handling
1 parent 3362f22 commit 7a62ca7

3 files changed

Lines changed: 111 additions & 33 deletions

File tree

libs/broker/src/broker-session.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -235,16 +235,24 @@ export class BrokerSession {
235235

236236
// Session was cancelled/terminated while enclave was running
237237
if (this.isTerminal()) {
238+
if (this._deadlineExceeded) {
239+
this.emitter.emit(
240+
this.makeCustomEvent(EventType.DeadlineExceeded, {
241+
elapsedMs: Date.now() - this.createdAt,
242+
budgetMs: this.limits.deadlineMs,
243+
}),
244+
);
245+
}
238246
const cancelError = {
239247
code: this._deadlineExceeded ? 'DEADLINE_EXCEEDED' : 'SESSION_CANCELLED',
240-
message: 'Session was cancelled',
248+
message: this._deadlineExceeded ? 'Deadline exceeded' : 'Session was cancelled',
241249
};
242250
this.emitter.emitFinalError(cancelError, eventStats, this.getPartialErrors());
243251
result = {
244252
success: false,
245253
error: { message: cancelError.message, name: 'Error', code: cancelError.code },
246254
stats,
247-
finalState: 'cancelled',
255+
finalState: this._deadlineExceeded ? 'failed' : 'cancelled',
248256
};
249257
} else if (enclaveResult.success) {
250258
this._state = 'completed';
@@ -377,6 +385,18 @@ export class BrokerSession {
377385
const callId = generateCallId();
378386
const toolStartTime = Date.now();
379387

388+
// Check session deadline before emitting any events
389+
if (this.limits.deadlineMs > 0) {
390+
const elapsed = Date.now() - this.createdAt;
391+
const remaining = this.limits.deadlineMs - elapsed;
392+
if (remaining <= 0) {
393+
this._deadlineExceeded = true;
394+
const err = new Error('Deadline exceeded before tool execution');
395+
(err as Error & { code?: string }).code = 'DEADLINE_EXCEEDED';
396+
throw err;
397+
}
398+
}
399+
380400
// Emit tool call event
381401
this.emitter.emitToolCall(callId, toolName, args);
382402
this.toolCallCount++;
@@ -387,14 +407,7 @@ export class BrokerSession {
387407
// Compute per-tool timeout, capped by remaining session budget
388408
let toolTimeout = this.limits.perToolDeadlineMs;
389409
if (this.limits.deadlineMs > 0) {
390-
const elapsed = Date.now() - this.createdAt;
391-
const remaining = this.limits.deadlineMs - elapsed;
392-
if (remaining <= 0) {
393-
this._deadlineExceeded = true;
394-
const err = new Error('Deadline exceeded before tool execution');
395-
(err as Error & { code?: string }).code = 'DEADLINE_EXCEEDED';
396-
throw err;
397-
}
410+
const remaining = this.limits.deadlineMs - (Date.now() - this.createdAt);
398411
toolTimeout = Math.min(toolTimeout, remaining);
399412
}
400413

libs/broker/src/openapi/openapi-spec-poller.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,14 @@ export class OpenApiSpecPoller extends EventEmitter {
198198
// Wait before retrying (abortable)
199199
const delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, attempt), maxDelayMs);
200200
await new Promise<void>((resolve) => {
201-
const timer = setTimeout(resolve, delay);
202201
const onAbort = () => {
203202
clearTimeout(timer);
204203
resolve();
205204
};
205+
const timer = setTimeout(() => {
206+
runSignal.removeEventListener('abort', onAbort);
207+
resolve();
208+
}, delay);
206209
runSignal.addEventListener('abort', onAbort, { once: true });
207210
});
208211
}
@@ -246,11 +249,9 @@ export class OpenApiSpecPoller extends EventEmitter {
246249
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
247250
}
248251

249-
// Store ETag and Last-Modified for next request
252+
// Read response headers (defer storing until body is fully processed)
250253
const etag = response.headers.get('etag');
251254
const lastModified = response.headers.get('last-modified');
252-
if (etag) this.lastEtag = etag;
253-
if (lastModified) this.lastModified = lastModified;
254255

255256
const body = await response.text();
256257

@@ -260,11 +261,17 @@ export class OpenApiSpecPoller extends EventEmitter {
260261

261262
if (this.lastHash && this.lastHash === hash) {
262263
this.emit('unchanged');
264+
if (etag) this.lastEtag = etag;
265+
if (lastModified) this.lastModified = lastModified;
263266
return;
264267
}
265268

266269
this.lastHash = hash;
267270
this.emit('changed', body, hash);
271+
272+
// Store headers only after successful body processing
273+
if (etag) this.lastEtag = etag;
274+
if (lastModified) this.lastModified = lastModified;
268275
} finally {
269276
clearTimeout(timeout);
270277
if (this._fetchController === controller) {

libs/broker/src/openapi/openapi-tool-loader.ts

Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,29 @@ export class OpenApiToolLoader {
132132
auth?: UpstreamAuth,
133133
): Promise<OpenApiToolLoader> {
134134
const hash = await sha256Hex(JSON.stringify(spec));
135-
return new OpenApiToolLoader(spec, hash, options, auth);
135+
136+
// Resolve baseUrl from spec.servers when not explicitly provided
137+
let resolvedBaseUrl = options?.baseUrl;
138+
if (!resolvedBaseUrl) {
139+
const servers = spec['servers'] as Array<{ url?: string }> | undefined;
140+
if (servers?.[0]?.url) {
141+
try {
142+
const serverUrl = new URL(servers[0].url);
143+
resolvedBaseUrl = serverUrl.origin + serverUrl.pathname;
144+
} catch {
145+
throw new Error(
146+
`Cannot resolve relative server URL "${servers[0].url}" without a source URL. Use fromURL() or provide an explicit baseUrl option.`,
147+
);
148+
}
149+
}
150+
}
151+
152+
return new OpenApiToolLoader(
153+
spec,
154+
hash,
155+
resolvedBaseUrl ? { ...options, baseUrl: resolvedBaseUrl } : options,
156+
auth,
157+
);
136158
}
137159

138160
/**
@@ -182,7 +204,7 @@ export class OpenApiToolLoader {
182204
? `${this.options.sourceName}_${baseName}`
183205
: baseName;
184206

185-
const argsSchema = this.buildArgsSchema(op);
207+
const { schema: argsSchema, bodyMediaType } = this.buildArgsSchema(op);
186208
const timeout = this.options.perToolDeadlineMs ?? 30000;
187209

188210
const tool: ToolDefinition = {
@@ -192,7 +214,7 @@ export class OpenApiToolLoader {
192214
config: {
193215
timeout,
194216
},
195-
handler: this.createHandler(op, baseUrl),
217+
handler: this.createHandler(op, baseUrl, bodyMediaType),
196218
};
197219

198220
this.tools.push(tool);
@@ -257,8 +279,9 @@ export class OpenApiToolLoader {
257279
/**
258280
* Build a Zod args schema from an OpenAPI operation.
259281
*/
260-
private buildArgsSchema(op: ParsedOperation): z.ZodType {
282+
private buildArgsSchema(op: ParsedOperation): { schema: z.ZodType; bodyMediaType?: string } {
261283
const shape: Record<string, z.ZodType> = {};
284+
let bodyMediaType: string | undefined;
262285

263286
// Add parameters with OpenAPI type mapping
264287
if (op.parameters) {
@@ -270,21 +293,23 @@ export class OpenApiToolLoader {
270293

271294
// Add request body as 'body' parameter, inspecting content media type
272295
if (op.requestBody?.content) {
273-
const bodySchema = this.buildBodySchema(op.requestBody.content);
296+
const { schema: bodySchema, mediaType } = this.buildBodySchema(op.requestBody.content);
274297
shape['body'] = op.requestBody.required ? bodySchema : bodySchema.optional();
298+
bodyMediaType = mediaType;
275299
} else if (op.requestBody) {
276300
const fallback = z.record(z.string(), z.unknown());
277301
shape['body'] = op.requestBody.required ? fallback : fallback.optional();
278302
}
279303

280-
return Object.keys(shape).length > 0 ? z.object(shape) : z.record(z.string(), z.unknown());
304+
const schema = Object.keys(shape).length > 0 ? z.object(shape) : z.record(z.string(), z.unknown());
305+
return { schema, bodyMediaType };
281306
}
282307

283308
/**
284309
* Map an OpenAPI schema type to the corresponding Zod type.
285310
*/
286311
private mapOpenApiType(schema?: Record<string, unknown>): z.ZodType {
287-
if (!schema || !schema['type']) return z.string();
312+
if (!schema || !schema['type']) return z.unknown();
288313

289314
const type = schema['type'] as string;
290315
const enumValues = schema['enum'] as string[] | undefined;
@@ -298,47 +323,56 @@ export class OpenApiToolLoader {
298323
return z.boolean();
299324
case 'array':
300325
return z.array(this.mapOpenApiType(schema['items'] as Record<string, unknown> | undefined));
326+
case 'object':
327+
return z.record(z.string(), z.unknown());
301328
case 'string':
302329
if (enumValues && enumValues.length > 0) {
303330
return z.enum(enumValues as [string, ...string[]]);
304331
}
305332
return z.string();
306333
default:
307-
return z.string();
334+
return z.unknown();
308335
}
309336
}
310337

311338
/**
312339
* Build a Zod schema for the request body based on the media type.
340+
* Returns both the schema and the chosen media type for correct serialization.
313341
*/
314-
private buildBodySchema(content: Record<string, { schema?: Record<string, unknown> }>): z.ZodType {
342+
private buildBodySchema(content: Record<string, { schema?: Record<string, unknown> }>): {
343+
schema: z.ZodType;
344+
mediaType: string;
345+
} {
315346
// Prefer JSON media types
316347
const jsonKey = Object.keys(content).find((k) => k.includes('json'));
317348
if (jsonKey) {
318349
const schema = content[jsonKey].schema;
319-
if (schema) return this.mapOpenApiType(schema);
320-
return z.record(z.string(), z.unknown());
350+
if (schema) return { schema: this.mapOpenApiType(schema), mediaType: jsonKey };
351+
return { schema: z.record(z.string(), z.unknown()), mediaType: jsonKey };
321352
}
322353

323354
// Form data
324-
if (content['application/x-www-form-urlencoded'] || content['multipart/form-data']) {
325-
return z.record(z.string(), z.unknown());
355+
if (content['application/x-www-form-urlencoded']) {
356+
return { schema: z.record(z.string(), z.unknown()), mediaType: 'application/x-www-form-urlencoded' };
357+
}
358+
if (content['multipart/form-data']) {
359+
return { schema: z.record(z.string(), z.unknown()), mediaType: 'multipart/form-data' };
326360
}
327361

328362
// Plain text
329363
const textKey = Object.keys(content).find((k) => k.startsWith('text/'));
330364
if (textKey) {
331-
return z.string();
365+
return { schema: z.string(), mediaType: textKey };
332366
}
333367

334368
// Fallback
335-
return z.record(z.string(), z.unknown());
369+
return { schema: z.record(z.string(), z.unknown()), mediaType: 'application/json' };
336370
}
337371

338372
/**
339373
* Create a handler function for an OpenAPI operation.
340374
*/
341-
private createHandler(op: ParsedOperation, baseUrl: string): ToolDefinition['handler'] {
375+
private createHandler(op: ParsedOperation, baseUrl: string, bodyMediaType?: string): ToolDefinition['handler'] {
342376
const auth = this.auth;
343377
const headers = this.options.headers ?? {};
344378

@@ -370,8 +404,11 @@ export class OpenApiToolLoader {
370404

371405
// Build request headers (only set Content-Type for methods with a body)
372406
const hasBody = ['post', 'put', 'patch'].includes(op.method) && params['body'] != null;
407+
const resolvedMediaType = bodyMediaType ?? 'application/json';
408+
const isMultipart = resolvedMediaType === 'multipart/form-data';
373409
const requestHeaders: Record<string, string> = {
374-
...(hasBody && { 'Content-Type': 'application/json' }),
410+
// Omit Content-Type for multipart/form-data to let the runtime set the boundary
411+
...(hasBody && !isMultipart && { 'Content-Type': resolvedMediaType }),
375412
...headers,
376413
};
377414

@@ -390,10 +427,16 @@ export class OpenApiToolLoader {
390427
}
391428
}
392429

393-
// Add header parameters
430+
// Add header parameters (skip protected auth headers to prevent credential overwrite)
431+
const protectedHeaders = new Set<string>();
432+
if (auth) {
433+
if (auth.type === 'bearer' || auth.type === 'basic') protectedHeaders.add('authorization');
434+
if (auth.type === 'api-key') protectedHeaders.add((auth.header ?? 'X-API-Key').toLowerCase());
435+
}
394436
if (op.parameters) {
395437
for (const param of op.parameters) {
396438
if (param.in === 'header' && params[param.name] !== undefined) {
439+
if (protectedHeaders.has(param.name.toLowerCase())) continue;
397440
requestHeaders[param.name] = String(params[param.name]);
398441
}
399442
}
@@ -407,7 +450,22 @@ export class OpenApiToolLoader {
407450
};
408451

409452
if (hasBody) {
410-
fetchOptions.body = JSON.stringify(params['body']);
453+
if (resolvedMediaType.includes('json')) {
454+
fetchOptions.body = JSON.stringify(params['body']);
455+
} else if (resolvedMediaType === 'application/x-www-form-urlencoded') {
456+
fetchOptions.body = new URLSearchParams(params['body'] as Record<string, string>).toString();
457+
} else if (resolvedMediaType === 'multipart/form-data') {
458+
const formData = new FormData();
459+
const bodyObj = params['body'] as Record<string, unknown>;
460+
for (const [key, value] of Object.entries(bodyObj)) {
461+
formData.append(key, value instanceof Blob ? value : String(value));
462+
}
463+
fetchOptions.body = formData;
464+
} else if (resolvedMediaType.startsWith('text/')) {
465+
fetchOptions.body = String(params['body']);
466+
} else {
467+
fetchOptions.body = JSON.stringify(params['body']);
468+
}
411469
}
412470

413471
const response = await fetch(url, fetchOptions);

0 commit comments

Comments
 (0)