@proteinjs/conversation 2.6.0 → 2.7.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/src/Conversation.d.ts.map +1 -1
  7. package/dist/src/Conversation.js +12 -16
  8. package/dist/src/Conversation.js.map +1 -1
  9. package/dist/src/OpenAi.js +3 -3
  10. package/dist/src/OpenAi.js.map +1 -1
  11. package/dist/src/OpenAiResponses.d.ts +41 -4
  12. package/dist/src/OpenAiResponses.d.ts.map +1 -1
  13. package/dist/src/OpenAiResponses.js +757 -77
  14. package/dist/src/OpenAiResponses.js.map +1 -1
  15. package/dist/src/OpenAiStreamProcessor.js +4 -4
  16. package/dist/src/OpenAiStreamProcessor.js.map +1 -1
  17. package/dist/src/UsageData.d.ts +39 -4
  18. package/dist/src/UsageData.d.ts.map +1 -1
  19. package/dist/src/UsageData.js +302 -11
  20. package/dist/src/UsageData.js.map +1 -1
  21. package/dist/src/fs/conversation_fs/ConversationFsModule.d.ts.map +1 -1
  22. package/dist/src/fs/conversation_fs/ConversationFsModule.js +1 -0
  23. package/dist/src/fs/conversation_fs/ConversationFsModule.js.map +1 -1
  24. package/dist/src/fs/conversation_fs/FsFunctions.d.ts +26 -0
  25. package/dist/src/fs/conversation_fs/FsFunctions.d.ts.map +1 -1
  26. package/dist/src/fs/conversation_fs/FsFunctions.js +68 -27
  27. package/dist/src/fs/conversation_fs/FsFunctions.js.map +1 -1
  28. package/index.ts +1 -1
  29. package/package.json +4 -4
  30. package/src/Conversation.ts +14 -17
  31. package/src/OpenAi.ts +3 -3
  32. package/src/OpenAiResponses.ts +905 -112
  33. package/src/OpenAiStreamProcessor.ts +3 -3
  34. package/src/UsageData.ts +376 -13
  35. package/src/fs/conversation_fs/ConversationFsModule.ts +2 -0
  36. package/src/fs/conversation_fs/FsFunctions.ts +32 -2
@@ -6,11 +6,23 @@ import type { Function } from './Function';
6
6
  import { UsageData, UsageDataAccumulator } from './UsageData';
7
7
  import { ChatCompletionMessageParamFactory } from './ChatCompletionMessageParamFactory';
8
8
  import type { GenerateResponseReturn, ToolInvocationProgressEvent, ToolInvocationResult } from './OpenAi';
9
- import { DEFAULT_MODEL } from './OpenAi';
9
+ import { TiktokenModel } from 'tiktoken';
10
10
 
11
- export const DEFAULT_RESPONSES_MODEL = 'gpt-5.2';
11
+ export const DEFAULT_RESPONSES_MODEL = 'gpt-5.2' as TiktokenModel;
12
12
  export const DEFAULT_MAX_TOOL_CALLS = 50;
13
13
 
14
+ /** Default hard cap for background-mode polling duration (ms): 1 hour. */
15
+ export const DEFAULT_MAX_BACKGROUND_WAIT_MS = 60 * 60 * 1000;
16
+
17
+ /** Best-effort timeout for cancel calls (avoid hanging abort/timeout paths). */
18
+ const DEFAULT_CANCEL_TIMEOUT_MS = 10_000;
19
+
20
+ /**
21
+ * Responses API service tier.
22
+ * See: Responses API `service_tier` request param and response field.
23
+ */
24
+ export type OpenAiServiceTier = 'auto' | 'default' | 'flex' | 'priority' | (string & {});
25
+
14
26
  export type OpenAiResponsesParams = {
15
27
  modules?: ConversationModule[];
16
28
  /** If provided, only these functions will be exposed to the model. */
@@ -18,18 +30,24 @@ export type OpenAiResponsesParams = {
18
30
  logLevel?: LogLevel;
19
31
 
20
32
  /** Default model when none is provided per call. */
21
- defaultModel?: string;
33
+ defaultModel?: TiktokenModel;
22
34
 
23
35
  /** Default cap for tool calls (per call). */
24
36
  maxToolCalls?: number;
37
+
38
+ /** Default hard cap for background-mode polling duration (ms). Default: 1 hour. */
39
+ maxBackgroundWaitMs?: number;
25
40
  };
26
41
 
27
42
  export type GenerateTextParams = {
28
43
  messages: (string | ChatCompletionMessageParam)[];
29
- model?: string;
44
+ model?: TiktokenModel;
30
45
 
31
46
  abortSignal?: AbortSignal;
32
47
 
48
+ /** Hard cap for background-mode polling duration (ms). Default: 1 hour. */
49
+ maxBackgroundWaitMs?: number;
50
+
33
51
  /** Sampling & limits */
34
52
  temperature?: number;
35
53
  topP?: number;
@@ -49,14 +67,20 @@ export type GenerateTextParams = {
49
67
 
50
68
  /** If true, run using Responses API background mode (polling). */
51
69
  backgroundMode?: boolean;
70
+
71
+ /** Optional Responses API service tier override (per-request). */
72
+ serviceTier?: OpenAiServiceTier;
52
73
  };
53
74
 
54
75
  export type ResponsesGenerateObjectParams<S> = {
55
76
  messages: (string | ChatCompletionMessageParam)[];
56
- model?: string;
77
+ model?: TiktokenModel;
57
78
 
58
79
  abortSignal?: AbortSignal;
59
80
 
81
+ /** Hard cap for background-mode polling duration (ms). Default: 1 hour. */
82
+ maxBackgroundWaitMs?: number;
83
+
60
84
  /** Zod schema or JSON Schema */
61
85
  schema: S;
62
86
 
@@ -79,6 +103,9 @@ export type ResponsesGenerateObjectParams<S> = {
79
103
 
80
104
  /** If true, run using Responses API background mode (polling). */
81
105
  backgroundMode?: boolean;
106
+
107
+ /** Optional Responses API service tier override (per-request). */
108
+ serviceTier?: OpenAiServiceTier;
82
109
  };
83
110
 
84
111
  /**
@@ -96,8 +123,9 @@ export class OpenAiResponses {
96
123
 
97
124
  private readonly modules: ConversationModule[];
98
125
  private readonly allowedFunctionNames?: string[];
99
- private readonly defaultModel: string;
126
+ private readonly defaultModel: TiktokenModel;
100
127
  private readonly defaultMaxToolCalls: number;
128
+ private readonly defaultMaxBackgroundWaitMs: number;
101
129
 
102
130
  private modulesProcessed = false;
103
131
  private processingModulesPromise: Promise<void> | null = null;
@@ -112,8 +140,15 @@ export class OpenAiResponses {
112
140
  this.modules = opts.modules ?? [];
113
141
  this.allowedFunctionNames = opts.allowedFunctionNames;
114
142
 
115
- this.defaultModel = (opts.defaultModel ?? DEFAULT_RESPONSES_MODEL).trim();
143
+ this.defaultModel = opts.defaultModel ?? DEFAULT_RESPONSES_MODEL;
116
144
  this.defaultMaxToolCalls = typeof opts.maxToolCalls === 'number' ? opts.maxToolCalls : DEFAULT_MAX_TOOL_CALLS;
145
+
146
+ this.defaultMaxBackgroundWaitMs =
147
+ typeof opts.maxBackgroundWaitMs === 'number' &&
148
+ Number.isFinite(opts.maxBackgroundWaitMs) &&
149
+ opts.maxBackgroundWaitMs > 0
150
+ ? Math.floor(opts.maxBackgroundWaitMs)
151
+ : DEFAULT_MAX_BACKGROUND_WAIT_MS;
117
152
  }
118
153
 
119
154
  /** Plain text generation (supports tool calling). */
@@ -128,6 +163,7 @@ export class OpenAiResponses {
128
163
  });
129
164
 
130
165
  const maxToolCalls = typeof args.maxToolCalls === 'number' ? args.maxToolCalls : this.defaultMaxToolCalls;
166
+ const maxBackgroundWaitMs = this.resolveMaxBackgroundWaitMs(args.maxBackgroundWaitMs);
131
167
 
132
168
  const result = await this.run({
133
169
  model,
@@ -140,7 +176,9 @@ export class OpenAiResponses {
140
176
  reasoningEffort: args.reasoningEffort,
141
177
  maxToolCalls,
142
178
  backgroundMode,
179
+ maxBackgroundWaitMs,
143
180
  textFormat: undefined,
181
+ serviceTier: args.serviceTier,
144
182
  });
145
183
 
146
184
  if (args.onUsageData) {
@@ -167,6 +205,7 @@ export class OpenAiResponses {
167
205
  });
168
206
 
169
207
  const maxToolCalls = typeof args.maxToolCalls === 'number' ? args.maxToolCalls : this.defaultMaxToolCalls;
208
+ const maxBackgroundWaitMs = this.resolveMaxBackgroundWaitMs(args.maxBackgroundWaitMs);
170
209
  const textFormat = this.buildTextFormat(args.schema);
171
210
 
172
211
  const result = await this.run({
@@ -180,10 +219,17 @@ export class OpenAiResponses {
180
219
  reasoningEffort: args.reasoningEffort,
181
220
  maxToolCalls,
182
221
  backgroundMode,
222
+ maxBackgroundWaitMs,
183
223
  textFormat,
224
+ serviceTier: args.serviceTier,
184
225
  });
185
226
 
186
- const object = this.parseAndValidateStructuredOutput<T>(result.message, args.schema);
227
+ const object = this.parseAndValidateStructuredOutput<T>(result.message, args.schema, {
228
+ model,
229
+ maxOutputTokens: args.maxTokens,
230
+ requestedServiceTier: args.serviceTier,
231
+ serviceTier: result.serviceTier,
232
+ });
187
233
 
188
234
  const outcome = {
189
235
  object,
@@ -202,7 +248,7 @@ export class OpenAiResponses {
202
248
  // -----------------------------------------
203
249
 
204
250
  private async run(args: {
205
- model: string;
251
+ model: TiktokenModel;
206
252
  messages: (string | ChatCompletionMessageParam)[];
207
253
 
208
254
  temperature?: number;
@@ -216,12 +262,15 @@ export class OpenAiResponses {
216
262
 
217
263
  maxToolCalls: number;
218
264
  backgroundMode: boolean;
265
+ maxBackgroundWaitMs: number;
219
266
 
220
267
  textFormat?: unknown;
221
- }): Promise<GenerateResponseReturn> {
268
+
269
+ serviceTier?: OpenAiServiceTier;
270
+ }): Promise<GenerateResponseReturn & { serviceTier?: OpenAiServiceTier }> {
222
271
  // UsageDataAccumulator is typed around TiktokenModel; keep accumulator model stable,
223
272
  // and (optionally) report the actual model via upstream telemetry if you later choose to.
224
- const usage = new UsageDataAccumulator({ model: DEFAULT_MODEL });
273
+ const usage = new UsageDataAccumulator({ model: args.model });
225
274
  const toolInvocations: ToolInvocationResult[] = [];
226
275
 
227
276
  const tools = this.buildResponseTools(this.functions);
@@ -235,7 +284,8 @@ export class OpenAiResponses {
235
284
  for (;;) {
236
285
  const response = await this.createResponseAndMaybeWait({
237
286
  model: args.model,
238
- instructions: previousResponseId ? undefined : instructions,
287
+ // Always pass instructions; they are not carried over with previous_response_id.
288
+ instructions,
239
289
  input: nextInput,
240
290
  previousResponseId,
241
291
  tools,
@@ -245,10 +295,22 @@ export class OpenAiResponses {
245
295
  reasoningEffort: args.reasoningEffort,
246
296
  textFormat: args.textFormat,
247
297
  backgroundMode: args.backgroundMode,
298
+ maxBackgroundWaitMs: args.maxBackgroundWaitMs,
248
299
  abortSignal: args.abortSignal,
300
+ serviceTier: args.serviceTier,
249
301
  });
250
302
 
251
- this.addUsageFromResponse(response, usage);
303
+ this.addUsageFromResponse(response, usage, { requestedServiceTier: args.serviceTier });
304
+
305
+ // For structured outputs we should not attempt to parse incomplete/failed/cancelled responses.
306
+ // For plain-text generation, we allow "incomplete" to pass through (partial output),
307
+ // but still fail on other non-completed statuses.
308
+ this.throwIfResponseUnusable(response as any, {
309
+ allowIncomplete: !args.textFormat,
310
+ model: args.model,
311
+ maxOutputTokens: args.maxTokens,
312
+ requestedServiceTier: args.serviceTier,
313
+ });
252
314
 
253
315
  const functionCalls = this.extractFunctionCalls(response);
254
316
  if (functionCalls.length < 1) {
@@ -256,7 +318,12 @@ export class OpenAiResponses {
256
318
  if (!message) {
257
319
  throw new Error(`Response was empty`);
258
320
  }
259
- return { message, usagedata: usage.usageData, toolInvocations };
321
+ return {
322
+ message,
323
+ usagedata: usage.usageData,
324
+ toolInvocations,
325
+ serviceTier: response.service_tier ? response.service_tier : undefined,
326
+ };
260
327
  }
261
328
 
262
329
  if (toolCallsExecuted + functionCalls.length > args.maxToolCalls) {
@@ -287,6 +354,263 @@ export class OpenAiResponses {
287
354
  }
288
355
  }
289
356
 
357
+ private throwIfResponseUnusable(
358
+ response: any,
359
+ opts: {
360
+ allowIncomplete: boolean;
361
+ model?: string;
362
+ maxOutputTokens?: number;
363
+ requestedServiceTier?: OpenAiServiceTier;
364
+ }
365
+ ): void {
366
+ const statusRaw = typeof response?.status === 'string' ? String(response.status) : '';
367
+ const status = statusRaw.toLowerCase();
368
+
369
+ if (!status || status === 'completed') {
370
+ return;
371
+ }
372
+
373
+ if (status === 'incomplete' && opts.allowIncomplete) {
374
+ return;
375
+ }
376
+
377
+ const id = typeof response?.id === 'string' ? response.id : '';
378
+ const reason = response?.incomplete_details?.reason;
379
+ const apiErr = response?.error;
380
+
381
+ const serviceTier =
382
+ typeof response?.service_tier === 'string' && response.service_tier.trim() ? response.service_tier.trim() : '';
383
+
384
+ const directOutputText = typeof response?.output_text === 'string' ? response.output_text : '';
385
+ const assistantText = this.extractAssistantText(response as any);
386
+
387
+ const outTextLen = directOutputText ? directOutputText.length : 0;
388
+ const assistantLen = assistantText ? assistantText.length : 0;
389
+
390
+ const usage = response?.usage;
391
+ const inputTokens = typeof usage?.input_tokens === 'number' ? usage.input_tokens : undefined;
392
+ const outputTokens = typeof usage?.output_tokens === 'number' ? usage.output_tokens : undefined;
393
+ const totalTokens =
394
+ typeof usage?.total_tokens === 'number'
395
+ ? usage.total_tokens
396
+ : typeof inputTokens === 'number' && typeof outputTokens === 'number'
397
+ ? inputTokens + outputTokens
398
+ : undefined;
399
+
400
+ let msg = `Responses API returned status="${status}"`;
401
+ if (id) {
402
+ msg += ` (id=${id})`;
403
+ }
404
+ msg += `.`;
405
+
406
+ const details: Record<string, unknown> = {
407
+ response_id: id || undefined,
408
+ status,
409
+ model: typeof opts.model === 'string' && opts.model.trim() ? opts.model : undefined,
410
+ max_output_tokens: typeof opts.maxOutputTokens === 'number' ? opts.maxOutputTokens : undefined,
411
+
412
+ requested_service_tier:
413
+ typeof opts.requestedServiceTier === 'string' && opts.requestedServiceTier.trim()
414
+ ? opts.requestedServiceTier.trim()
415
+ : undefined,
416
+ service_tier: serviceTier || undefined,
417
+
418
+ incomplete_reason: typeof reason === 'string' && reason.trim() ? reason : undefined,
419
+ api_error: apiErr ?? undefined,
420
+
421
+ usage_input_tokens: inputTokens,
422
+ usage_output_tokens: outputTokens,
423
+ usage_total_tokens: totalTokens,
424
+
425
+ output_text_len: outTextLen || undefined,
426
+ output_text_tail: outTextLen > 0 ? truncateTail(directOutputText, 400) : undefined,
427
+
428
+ assistant_text_len: assistantLen || undefined,
429
+ assistant_text_tail: assistantLen > 0 ? truncateTail(assistantText, 400) : undefined,
430
+ };
431
+
432
+ const extra: string[] = [];
433
+ if (details.model) {
434
+ extra.push(`model=${details.model}`);
435
+ }
436
+ if (typeof details.max_output_tokens === 'number') {
437
+ extra.push(`max_output_tokens=${details.max_output_tokens}`);
438
+ }
439
+ if (typeof details.requested_service_tier === 'string') {
440
+ extra.push(`requested_service_tier=${details.requested_service_tier}`);
441
+ }
442
+ if (typeof details.service_tier === 'string') {
443
+ extra.push(`service_tier=${details.service_tier}`);
444
+ }
445
+ if (details.incomplete_reason) {
446
+ extra.push(`reason=${details.incomplete_reason}`);
447
+ }
448
+ if (typeof details.output_text_len === 'number') {
449
+ extra.push(`output_text_len=${details.output_text_len}`);
450
+ }
451
+ if (typeof details.assistant_text_len === 'number') {
452
+ extra.push(`assistant_text_len=${details.assistant_text_len}`);
453
+ }
454
+
455
+ if (extra.length > 0) {
456
+ msg += ` ${extra.join(' ')}.`;
457
+ }
458
+
459
+ throw new OpenAiResponsesError({
460
+ code: 'RESPONSE_STATUS',
461
+ message: msg,
462
+ details,
463
+ });
464
+ }
465
+
466
+ private toOpenAiApiError(
467
+ error: unknown,
468
+ meta: {
469
+ operation: 'responses.create' | 'responses.retrieve' | 'responses.cancel';
470
+ model?: string;
471
+ reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
472
+ backgroundMode?: boolean;
473
+ responseId?: string;
474
+ previousResponseId?: string;
475
+ pollAttempt?: number;
476
+ aborted?: boolean;
477
+ waitedMs?: number;
478
+ maxWaitMs?: number;
479
+ lastStatus?: string;
480
+ requestedServiceTier?: OpenAiServiceTier;
481
+ serviceTier?: string;
482
+ }
483
+ ): OpenAiResponsesError {
484
+ const status = extractHttpStatus(error);
485
+ const requestId = extractRequestId(error);
486
+ const retryable = isRetryableHttpStatus(status);
487
+
488
+ const errMsg = error instanceof Error ? error.message : String(error ?? '');
489
+ const errName = error instanceof Error ? error.name : undefined;
490
+
491
+ const aborted = meta.aborted === true || isAbortError(error);
492
+
493
+ let msg = `OpenAI ${meta.operation} failed.`;
494
+ const extra: string[] = [];
495
+
496
+ if (aborted) {
497
+ extra.push(`aborted=true`);
498
+ }
499
+ if (typeof status === 'number') {
500
+ extra.push(`status=${status}`);
501
+ }
502
+ if (requestId) {
503
+ extra.push(`requestId=${requestId}`);
504
+ }
505
+ if (meta.responseId) {
506
+ extra.push(`responseId=${meta.responseId}`);
507
+ }
508
+ if (meta.backgroundMode) {
509
+ extra.push(`background=true`);
510
+ }
511
+ if (typeof meta.pollAttempt === 'number') {
512
+ extra.push(`pollAttempt=${meta.pollAttempt}`);
513
+ }
514
+ if (typeof meta.waitedMs === 'number') {
515
+ extra.push(`waitedMs=${meta.waitedMs}`);
516
+ }
517
+ if (typeof meta.maxWaitMs === 'number') {
518
+ extra.push(`maxWaitMs=${meta.maxWaitMs}`);
519
+ }
520
+ if (typeof meta.lastStatus === 'string' && meta.lastStatus.trim()) {
521
+ extra.push(`lastStatus=${meta.lastStatus.trim()}`);
522
+ }
523
+ if (typeof meta.model === 'string' && meta.model.trim()) {
524
+ extra.push(`model=${meta.model.trim()}`);
525
+ }
526
+ if (meta.reasoningEffort) {
527
+ extra.push(`reasoningEffort=${meta.reasoningEffort}`);
528
+ }
529
+ if (typeof meta.requestedServiceTier === 'string' && meta.requestedServiceTier.trim()) {
530
+ extra.push(`requested_service_tier=${meta.requestedServiceTier.trim()}`);
531
+ }
532
+ if (typeof meta.serviceTier === 'string' && meta.serviceTier.trim()) {
533
+ extra.push(`service_tier=${meta.serviceTier.trim()}`);
534
+ }
535
+
536
+ if (extra.length > 0) {
537
+ msg += ` ${extra.join(' ')}.`;
538
+ }
539
+ if (errMsg) {
540
+ msg += ` error=${JSON.stringify(errMsg)}.`;
541
+ }
542
+
543
+ const details: Record<string, unknown> = {
544
+ operation: meta.operation,
545
+ status: typeof status === 'number' ? status : undefined,
546
+ request_id: requestId,
547
+ response_id: meta.responseId,
548
+ previous_response_id: meta.previousResponseId,
549
+ background: meta.backgroundMode ? true : undefined,
550
+ poll_attempt: meta.pollAttempt,
551
+ waited_ms: meta.waitedMs,
552
+ max_wait_ms: meta.maxWaitMs,
553
+ last_status: typeof meta.lastStatus === 'string' && meta.lastStatus.trim() ? meta.lastStatus.trim() : undefined,
554
+ model: typeof meta.model === 'string' && meta.model.trim() ? meta.model.trim() : undefined,
555
+ reasoning_effort: meta.reasoningEffort,
556
+ requested_service_tier:
557
+ typeof meta.requestedServiceTier === 'string' && meta.requestedServiceTier.trim()
558
+ ? meta.requestedServiceTier.trim()
559
+ : undefined,
560
+ service_tier:
561
+ typeof meta.serviceTier === 'string' && meta.serviceTier.trim() ? meta.serviceTier.trim() : undefined,
562
+ error_name: errName,
563
+ aborted: aborted ? true : undefined,
564
+ };
565
+
566
+ return new OpenAiResponsesError({
567
+ code: 'OPENAI_API',
568
+ message: msg,
569
+ details,
570
+ cause: error,
571
+ retryable,
572
+ });
573
+ }
574
+
575
+ private resolveMaxBackgroundWaitMs(ms?: number): number {
576
+ const n =
577
+ typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms) : this.defaultMaxBackgroundWaitMs;
578
+ // Ensure we never return a non-positive number even if misconfigured elsewhere.
579
+ return n > 0 ? n : DEFAULT_MAX_BACKGROUND_WAIT_MS;
580
+ }
581
+
582
+ private async cancelResponseBestEffort(
583
+ responseId: string
584
+ ): Promise<
585
+ | { attempted: false }
586
+ | { attempted: true; ok: true }
587
+ | { attempted: true; ok: false; error?: Record<string, unknown> }
588
+ > {
589
+ if (!responseId) {
590
+ return { attempted: false };
591
+ }
592
+
593
+ try {
594
+ const resp = await this.client.responses.cancel(responseId);
595
+
596
+ // Docs show cancelled as the post-cancel status.
597
+ if (resp?.status === 'cancelled') {
598
+ return { attempted: true, ok: true };
599
+ }
600
+
601
+ return {
602
+ attempted: true,
603
+ ok: false,
604
+ error: {
605
+ message: 'Cancel did not return status=cancelled',
606
+ status: resp?.status,
607
+ },
608
+ };
609
+ } catch (e: unknown) {
610
+ return { attempted: true, ok: false, error: safeErrorSummary(e) };
611
+ }
612
+ }
613
+
290
614
  private async createResponseAndMaybeWait(args: {
291
615
  model: string;
292
616
  instructions?: string;
@@ -302,14 +626,11 @@ export class OpenAiResponses {
302
626
  textFormat?: unknown;
303
627
 
304
628
  backgroundMode: boolean;
629
+ maxBackgroundWaitMs: number;
305
630
  abortSignal?: AbortSignal;
306
- }): Promise<{
307
- id?: string;
308
- status?: string;
309
- output_text?: string;
310
- output?: unknown[];
311
- usage?: unknown;
312
- }> {
631
+
632
+ serviceTier?: OpenAiServiceTier;
633
+ }): Promise<OpenAIApi.Responses.Response> {
313
634
  const body: Record<string, unknown> = {
314
635
  model: args.model,
315
636
  input: args.input,
@@ -343,77 +664,197 @@ export class OpenAiResponses {
343
664
  body.text = { format: args.textFormat };
344
665
  }
345
666
 
667
+ if (typeof args.serviceTier === 'string' && args.serviceTier.trim()) {
668
+ body.service_tier = args.serviceTier.trim();
669
+ }
670
+
346
671
  if (args.backgroundMode) {
347
672
  body.background = true;
348
673
  body.store = true;
349
674
  }
350
675
 
351
- const created = await this.client.responses.create(
352
- body as never,
353
- args.abortSignal ? { signal: args.abortSignal } : undefined
354
- );
676
+ let created: OpenAIApi.Responses.Response;
677
+ try {
678
+ created = await this.client.responses.create(
679
+ body as never,
680
+ args.abortSignal ? { signal: args.abortSignal } : undefined
681
+ );
682
+ } catch (error: unknown) {
683
+ throw this.toOpenAiApiError(error, {
684
+ operation: 'responses.create',
685
+ model: args.model,
686
+ reasoningEffort: args.reasoningEffort,
687
+ backgroundMode: args.backgroundMode,
688
+ previousResponseId: args.previousResponseId,
689
+ aborted: args.abortSignal?.aborted ? true : undefined,
690
+ requestedServiceTier: args.serviceTier,
691
+ });
692
+ }
355
693
 
356
694
  if (!args.backgroundMode) {
357
- return created as unknown as {
358
- id?: string;
359
- status?: string;
360
- output_text?: string;
361
- output?: unknown[];
362
- usage?: unknown;
363
- };
695
+ return created;
364
696
  }
365
697
 
366
698
  if (!created?.id) {
367
- return created as unknown as {
368
- id?: string;
369
- status?: string;
370
- output_text?: string;
371
- output?: unknown[];
372
- usage?: unknown;
373
- };
699
+ return created;
374
700
  }
375
701
 
376
- return await this.waitForCompletion(created.id, args.abortSignal);
702
+ return await this.waitForCompletion(created.id, args.abortSignal, {
703
+ model: args.model,
704
+ reasoningEffort: args.reasoningEffort,
705
+ maxWaitMs: this.resolveMaxBackgroundWaitMs(args.maxBackgroundWaitMs),
706
+ requestedServiceTier: args.serviceTier,
707
+ });
377
708
  }
378
709
 
379
710
  private async waitForCompletion(
380
711
  responseId: string,
381
- abortSignal?: AbortSignal
382
- ): Promise<{
383
- id?: string;
384
- status?: string;
385
- output_text?: string;
386
- output?: unknown[];
387
- usage?: unknown;
388
- }> {
389
- let delayMs = 500;
712
+ abortSignal?: AbortSignal,
713
+ ctx?: {
714
+ model?: string;
715
+ reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
716
+ maxWaitMs?: number;
717
+ requestedServiceTier?: OpenAiServiceTier;
718
+ }
719
+ ): Promise<OpenAIApi.Responses.Response> {
720
+ this.logger.debug({ message: 'Waiting for completion', obj: { responseId } });
721
+ const maxWaitMs = this.resolveMaxBackgroundWaitMs(ctx?.maxWaitMs);
722
+
723
+ const startedAtMs = Date.now();
724
+
725
+ const delayMs = 1000;
726
+ let pollAttempt = 0;
727
+
728
+ let lastStatus = '';
729
+ let cancelAttempted = false;
730
+
731
+ const warnEveryMs = 10 * 60 * 1000;
732
+ let nextWarnAtMs = warnEveryMs;
733
+
734
+ const throwPollingStop = async (args: { kind: 'aborted' | 'timeout'; cause?: unknown }): Promise<never> => {
735
+ const waitedMs = Date.now() - startedAtMs;
736
+
737
+ // Best-effort cancellation to stop server-side work when we're done waiting.
738
+ let cancel: Awaited<ReturnType<OpenAiResponses['cancelResponseBestEffort']>> | undefined = undefined;
739
+ if (!cancelAttempted) {
740
+ cancelAttempted = true;
741
+ cancel = await this.cancelResponseBestEffort(responseId);
742
+ }
743
+
744
+ const baseDetails: Record<string, unknown> = {
745
+ operation: 'responses.retrieve',
746
+ response_id: responseId,
747
+ background: true,
748
+ poll_attempt: pollAttempt,
749
+ waited_ms: waitedMs,
750
+ max_wait_ms: maxWaitMs,
751
+ last_status: lastStatus || undefined,
752
+ model: typeof ctx?.model === 'string' && ctx.model.trim() ? ctx.model.trim() : undefined,
753
+ reasoning_effort: ctx?.reasoningEffort,
754
+ requested_service_tier:
755
+ typeof ctx?.requestedServiceTier === 'string' && ctx.requestedServiceTier.trim()
756
+ ? ctx.requestedServiceTier.trim()
757
+ : undefined,
758
+ aborted: args.kind === 'aborted' ? true : undefined,
759
+ timeout: args.kind === 'timeout' ? true : undefined,
760
+ cancel_attempted: cancel?.attempted ? true : undefined,
761
+ cancel_ok: cancel && cancel.attempted && 'ok' in cancel ? (cancel as any).ok : undefined,
762
+ cancel_timed_out: cancel && cancel.attempted && (cancel as any).timedOut ? true : undefined,
763
+ cancel_error: cancel && cancel.attempted && (cancel as any).error ? (cancel as any).error : undefined,
764
+ };
765
+
766
+ if (args.cause) {
767
+ baseDetails.polling_cause = safeErrorSummary(args.cause);
768
+ }
769
+
770
+ const msg =
771
+ args.kind === 'timeout'
772
+ ? `Background response exceeded max wait (maxWaitMs=${maxWaitMs}) while polling (id=${responseId}).`
773
+ : `Background polling aborted (id=${responseId}).`;
774
+
775
+ throw new OpenAiResponsesError({
776
+ code: 'OPENAI_API',
777
+ message: msg,
778
+ details: baseDetails,
779
+ cause: args.cause,
780
+ });
781
+ };
390
782
 
391
783
  for (;;) {
784
+ const waitedMs = Date.now() - startedAtMs;
785
+
786
+ // Abort wins immediately.
392
787
  if (abortSignal?.aborted) {
393
- throw new Error(`Request aborted`);
788
+ await throwPollingStop({ kind: 'aborted' });
394
789
  }
395
790
 
396
- const resp = await this.client.responses.retrieve(
397
- responseId,
398
- undefined,
399
- abortSignal ? { signal: abortSignal } : undefined
400
- );
791
+ // Max wait cap (1h default) to prevent runaway polling.
792
+ if (waitedMs >= maxWaitMs) {
793
+ await throwPollingStop({ kind: 'timeout' });
794
+ }
401
795
 
402
- const status = typeof (resp as any)?.status === 'string' ? String((resp as any).status).toLowerCase() : '';
403
- if (status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'incomplete') {
404
- return resp as unknown as {
405
- id?: string;
406
- status?: string;
407
- output_text?: string;
408
- output?: unknown[];
409
- usage?: unknown;
410
- };
796
+ // Warn every 10 minutes elapsed (best-effort; may log slightly after the boundary).
797
+ if (waitedMs >= nextWarnAtMs) {
798
+ nextWarnAtMs += warnEveryMs;
799
+
800
+ this.logger.warn({
801
+ message: `Background polling still in progress`,
802
+ obj: {
803
+ responseId,
804
+ status: lastStatus || undefined,
805
+ waitedMs,
806
+ pollAttempt,
807
+ model: typeof ctx?.model === 'string' && ctx.model.trim() ? ctx.model.trim() : undefined,
808
+ reasoningEffort: ctx?.reasoningEffort,
809
+ serviceTier:
810
+ typeof ctx?.requestedServiceTier === 'string' && ctx.requestedServiceTier.trim()
811
+ ? ctx.requestedServiceTier.trim()
812
+ : undefined,
813
+ },
814
+ });
411
815
  }
412
816
 
413
- this.logger.debug({ message: `Polling response`, obj: { responseId, status, delayMs } });
817
+ pollAttempt += 1;
818
+
819
+ let resp: OpenAIApi.Responses.Response;
820
+ try {
821
+ resp = await this.client.responses.retrieve(
822
+ responseId,
823
+ undefined,
824
+ abortSignal ? { signal: abortSignal } : undefined
825
+ );
826
+ } catch (error: unknown) {
827
+ // If the request was aborted mid-flight, treat it as an abort and still attempt cancellation.
828
+ if (abortSignal?.aborted || isAbortError(error)) {
829
+ await throwPollingStop({ kind: 'aborted', cause: error });
830
+ }
831
+
832
+ throw this.toOpenAiApiError(error, {
833
+ operation: 'responses.retrieve',
834
+ model: ctx?.model,
835
+ reasoningEffort: ctx?.reasoningEffort,
836
+ backgroundMode: true,
837
+ responseId,
838
+ pollAttempt,
839
+ waitedMs,
840
+ maxWaitMs,
841
+ lastStatus,
842
+ requestedServiceTier: ctx?.requestedServiceTier,
843
+ });
844
+ }
414
845
 
415
- await sleep(delayMs);
416
- delayMs = Math.min(5000, Math.floor(delayMs * 1.5));
846
+ const status = typeof resp?.status === 'string' ? resp.status : '';
847
+ lastStatus = status;
848
+
849
+ // Terminal states
850
+ if (status === 'completed' || status === 'failed' || status === 'incomplete' || status === 'cancelled') {
851
+ return resp;
852
+ }
853
+
854
+ this.logger.debug({ message: `Polling response`, obj: { responseId, status, delayMs, pollAttempt, waitedMs } });
855
+
856
+ // Sleep but wake early if aborted, so abort latency is low.
857
+ await sleepWithAbort(delayMs, abortSignal);
417
858
  }
418
859
  }
419
860
 
@@ -641,48 +1082,32 @@ export class OpenAiResponses {
641
1082
  // Usage + text extraction
642
1083
  // -----------------------------------------
643
1084
 
644
- private addUsageFromResponse(response: { usage?: unknown }, usage: UsageDataAccumulator): void {
645
- const u = response.usage;
646
- if (!u || typeof u !== 'object') {
1085
+ private addUsageFromResponse(
1086
+ response: OpenAIApi.Responses.Response,
1087
+ usage: UsageDataAccumulator,
1088
+ ctx?: { requestedServiceTier?: OpenAiServiceTier }
1089
+ ): void {
1090
+ if (!response.usage) {
647
1091
  return;
648
1092
  }
649
1093
 
650
- const rec = u as Record<string, unknown>;
651
- const input = typeof rec.input_tokens === 'number' ? rec.input_tokens : 0;
652
- const output = typeof rec.output_tokens === 'number' ? rec.output_tokens : 0;
653
- const total = typeof rec.total_tokens === 'number' ? rec.total_tokens : input + output;
654
-
655
- let cached = 0;
656
- let reasoning = 0;
657
-
658
- const inputDetails = rec.input_tokens_details;
659
- if (inputDetails && typeof inputDetails === 'object') {
660
- const id = inputDetails as Record<string, unknown>;
661
- cached = typeof id.cached_tokens === 'number' ? id.cached_tokens : 0;
662
- }
663
-
664
- const outputDetails = rec.output_tokens_details;
665
- if (outputDetails && typeof outputDetails === 'object') {
666
- const od = outputDetails as Record<string, unknown>;
667
- reasoning = typeof od.reasoning_tokens === 'number' ? od.reasoning_tokens : 0;
668
- }
669
-
670
- usage.addTokenUsage({
671
- promptTokens: input,
672
- cachedPromptTokens: cached,
673
- completionTokens: output,
674
- reasoningTokens: reasoning,
675
- totalTokens: total,
676
- });
1094
+ usage.addTokenUsage(
1095
+ {
1096
+ inputTokens: response.usage.input_tokens,
1097
+ cachedInputTokens: response.usage.input_tokens_details.cached_tokens,
1098
+ outputTokens: response.usage.output_tokens,
1099
+ reasoningTokens: response.usage.output_tokens_details.reasoning_tokens,
1100
+ totalTokens: response.usage.total_tokens,
1101
+ },
1102
+ { serviceTier: response.service_tier ?? ctx?.requestedServiceTier }
1103
+ );
677
1104
  }
678
1105
 
679
1106
  private extractAssistantText(response: { output_text?: string; output?: unknown[] }): string {
680
- const direct = typeof response.output_text === 'string' ? response.output_text.trim() : '';
681
- if (direct) {
682
- return direct;
683
- }
684
-
685
1107
  const out = Array.isArray(response.output) ? response.output : [];
1108
+
1109
+ let lastJoined = '';
1110
+
686
1111
  for (const item of out) {
687
1112
  if (!item || typeof item !== 'object') {
688
1113
  continue;
@@ -717,10 +1142,19 @@ export class OpenAiResponses {
717
1142
 
718
1143
  const joined = pieces.join('\n').trim();
719
1144
  if (joined) {
720
- return joined;
1145
+ lastJoined = joined;
721
1146
  }
722
1147
  }
723
1148
 
1149
+ if (lastJoined) {
1150
+ return lastJoined;
1151
+ }
1152
+
1153
+ const direct = typeof response.output_text === 'string' ? response.output_text.trim() : '';
1154
+ if (direct) {
1155
+ return direct;
1156
+ }
1157
+
724
1158
  return '';
725
1159
  }
726
1160
 
@@ -744,8 +1178,12 @@ export class OpenAiResponses {
744
1178
  };
745
1179
  }
746
1180
 
747
- private parseAndValidateStructuredOutput<T>(text: string, schema: unknown): T {
748
- const parsed = this.parseJson(text);
1181
+ private parseAndValidateStructuredOutput<T>(
1182
+ text: string,
1183
+ schema: unknown,
1184
+ ctx?: { model?: string; maxOutputTokens?: number; requestedServiceTier?: OpenAiServiceTier; serviceTier?: string }
1185
+ ): T {
1186
+ const parsed = this.parseJson(text, ctx);
749
1187
 
750
1188
  if (this.isZodSchema(schema)) {
751
1189
  const res = schema.safeParse(parsed);
@@ -765,7 +1203,10 @@ export class OpenAiResponses {
765
1203
  return typeof (schema as any).safeParse === 'function';
766
1204
  }
767
1205
 
768
- private parseJson(text: string): any {
1206
+ private parseJson(
1207
+ text: string,
1208
+ ctx?: { model?: string; maxOutputTokens?: number; requestedServiceTier?: OpenAiServiceTier; serviceTier?: string }
1209
+ ): any {
769
1210
  const cleaned = String(text ?? '')
770
1211
  .trim()
771
1212
  .replace(/^```(?:json)?/i, '')
@@ -774,7 +1215,9 @@ export class OpenAiResponses {
774
1215
 
775
1216
  try {
776
1217
  return JSON.parse(cleaned);
777
- } catch {
1218
+ } catch (err1: unknown) {
1219
+ const firstErrMsg = err1 instanceof Error ? err1.message : String(err1);
1220
+
778
1221
  const s = cleaned;
779
1222
  const firstObj = s.indexOf('{');
780
1223
  const firstArr = s.indexOf('[');
@@ -785,10 +1228,102 @@ export class OpenAiResponses {
785
1228
  const end = Math.max(lastObj, lastArr);
786
1229
 
787
1230
  if (start >= 0 && end > start) {
788
- return JSON.parse(s.slice(start, end + 1));
1231
+ const candidate = s.slice(start, end + 1);
1232
+ try {
1233
+ return JSON.parse(candidate);
1234
+ } catch (err2: unknown) {
1235
+ const secondErrMsg = err2 instanceof Error ? err2.message : String(err2);
1236
+
1237
+ const pos2rel = extractJsonParsePosition(secondErrMsg);
1238
+ const pos2 = typeof pos2rel === 'number' ? start + pos2rel : undefined;
1239
+
1240
+ const pos1 = extractJsonParsePosition(firstErrMsg);
1241
+ const pos = typeof pos2 === 'number' ? pos2 : pos1;
1242
+
1243
+ const lc = extractJsonParseLineCol(secondErrMsg) ?? extractJsonParseLineCol(firstErrMsg);
1244
+
1245
+ const details: Record<string, unknown> = {
1246
+ model: typeof ctx?.model === 'string' && ctx.model.trim() ? ctx.model : undefined,
1247
+ max_output_tokens: typeof ctx?.maxOutputTokens === 'number' ? ctx.maxOutputTokens : undefined,
1248
+
1249
+ requested_service_tier:
1250
+ typeof ctx?.requestedServiceTier === 'string' && String(ctx.requestedServiceTier).trim()
1251
+ ? String(ctx.requestedServiceTier).trim()
1252
+ : undefined,
1253
+ service_tier:
1254
+ typeof ctx?.serviceTier === 'string' && ctx.serviceTier.trim() ? ctx.serviceTier.trim() : undefined,
1255
+
1256
+ cleaned_len: s.length,
1257
+ cleaned_head: truncateHead(s, 250),
1258
+ cleaned_tail: truncateTail(s, 500),
1259
+
1260
+ json_start: start,
1261
+ json_end: end,
1262
+ json_candidate_len: candidate.length,
1263
+
1264
+ first_error: firstErrMsg,
1265
+ second_error: secondErrMsg,
1266
+
1267
+ error_pos: typeof pos === 'number' ? pos : undefined,
1268
+ error_line: lc?.line,
1269
+ error_column: lc?.column,
1270
+ error_context: typeof pos === 'number' ? snippetAround(s, pos, 160) : undefined,
1271
+ };
1272
+
1273
+ const msg =
1274
+ `Failed to parse model output as JSON. ` +
1275
+ `cleaned_len=${s.length} json_start=${start} json_end=${end}. ` +
1276
+ `first_error=${JSON.stringify(firstErrMsg)} second_error=${JSON.stringify(secondErrMsg)}.`;
1277
+
1278
+ throw new OpenAiResponsesError({
1279
+ code: 'JSON_PARSE',
1280
+ message: msg,
1281
+ details,
1282
+ cause: err2,
1283
+ });
1284
+ }
789
1285
  }
790
1286
 
791
- throw new Error(`Failed to parse model output as JSON`);
1287
+ const pos = extractJsonParsePosition(firstErrMsg);
1288
+ const lc = extractJsonParseLineCol(firstErrMsg);
1289
+
1290
+ const details: Record<string, unknown> = {
1291
+ model: typeof ctx?.model === 'string' && ctx.model.trim() ? ctx.model : undefined,
1292
+ max_output_tokens: typeof ctx?.maxOutputTokens === 'number' ? ctx.maxOutputTokens : undefined,
1293
+
1294
+ requested_service_tier:
1295
+ typeof ctx?.requestedServiceTier === 'string' && String(ctx.requestedServiceTier).trim()
1296
+ ? String(ctx.requestedServiceTier).trim()
1297
+ : undefined,
1298
+ service_tier:
1299
+ typeof ctx?.serviceTier === 'string' && ctx.serviceTier.trim() ? ctx.serviceTier.trim() : undefined,
1300
+
1301
+ cleaned_len: s.length,
1302
+ cleaned_head: truncateHead(s, 250),
1303
+ cleaned_tail: truncateTail(s, 500),
1304
+
1305
+ json_start: start >= 0 ? start : undefined,
1306
+ json_end: end >= 0 ? end : undefined,
1307
+
1308
+ first_error: firstErrMsg,
1309
+
1310
+ error_pos: typeof pos === 'number' ? pos : undefined,
1311
+ error_line: lc?.line,
1312
+ error_column: lc?.column,
1313
+ error_context: typeof pos === 'number' ? snippetAround(s, pos, 160) : undefined,
1314
+ };
1315
+
1316
+ const msg =
1317
+ `Failed to parse model output as JSON. ` +
1318
+ `cleaned_len=${s.length}. ` +
1319
+ `error=${JSON.stringify(firstErrMsg)}.`;
1320
+
1321
+ throw new OpenAiResponsesError({
1322
+ code: 'JSON_PARSE',
1323
+ message: msg,
1324
+ details,
1325
+ cause: err1,
1326
+ });
792
1327
  }
793
1328
  }
794
1329
 
@@ -1038,9 +1573,8 @@ export class OpenAiResponses {
1038
1573
  // Model/background defaults
1039
1574
  // -----------------------------------------
1040
1575
 
1041
- private resolveModel(model?: string): string {
1042
- const m = (model ?? this.defaultModel).trim();
1043
- return m.length > 0 ? m : DEFAULT_RESPONSES_MODEL;
1576
+ private resolveModel(model?: TiktokenModel): TiktokenModel {
1577
+ return model ?? this.defaultModel;
1044
1578
  }
1045
1579
 
1046
1580
  private resolveBackgroundMode(args: {
@@ -1071,6 +1605,265 @@ export class OpenAiResponses {
1071
1605
  }
1072
1606
  }
1073
1607
 
1608
+ export type OpenAiResponsesErrorCode = 'OPENAI_API' | 'RESPONSE_STATUS' | 'JSON_PARSE';
1609
+
1610
+ export class OpenAiResponsesError extends Error {
1611
+ public readonly code: OpenAiResponsesErrorCode;
1612
+ public readonly details: Record<string, unknown>;
1613
+ public readonly cause?: unknown;
1614
+ public readonly retryable: boolean;
1615
+
1616
+ constructor(args: {
1617
+ code: OpenAiResponsesErrorCode;
1618
+ message: string;
1619
+ details?: Record<string, unknown>;
1620
+ cause?: unknown;
1621
+ retryable?: boolean;
1622
+ }) {
1623
+ super(args.message);
1624
+ this.name = 'OpenAiResponsesError';
1625
+ this.code = args.code;
1626
+ this.details = args.details ?? {};
1627
+ this.cause = args.cause;
1628
+ this.retryable = typeof args.retryable === 'boolean' ? args.retryable : true;
1629
+ Object.setPrototypeOf(this, new.target.prototype);
1630
+ }
1631
+ }
1632
+
1633
+ function truncateHead(text: string, max: number): string {
1634
+ const s = String(text ?? '');
1635
+ if (max <= 0) {
1636
+ return '';
1637
+ }
1638
+ if (s.length <= max) {
1639
+ return s;
1640
+ }
1641
+ return s.slice(0, max) + '...';
1642
+ }
1643
+
1644
+ function truncateTail(text: string, max: number): string {
1645
+ const s = String(text ?? '');
1646
+ if (max <= 0) {
1647
+ return '';
1648
+ }
1649
+ if (s.length <= max) {
1650
+ return s;
1651
+ }
1652
+ return '...' + s.slice(s.length - max);
1653
+ }
1654
+
1655
+ function extractJsonParsePosition(errMsg: string): number | undefined {
1656
+ const m = String(errMsg ?? '').match(/at position\s+(\d+)/i);
1657
+ if (!m) {
1658
+ return undefined;
1659
+ }
1660
+ const n = Number(m[1]);
1661
+ return Number.isFinite(n) ? n : undefined;
1662
+ }
1663
+
1664
+ function extractJsonParseLineCol(errMsg: string): { line?: number; column?: number } | undefined {
1665
+ const m = String(errMsg ?? '').match(/line\s+(\d+)\s+column\s+(\d+)/i);
1666
+ if (!m) {
1667
+ return undefined;
1668
+ }
1669
+ const line = Number(m[1]);
1670
+ const column = Number(m[2]);
1671
+ return {
1672
+ line: Number.isFinite(line) ? line : undefined,
1673
+ column: Number.isFinite(column) ? column : undefined,
1674
+ };
1675
+ }
1676
+
1677
+ function snippetAround(text: string, pos: number, radius: number): string {
1678
+ const s = String(text ?? '');
1679
+ const p = Math.max(0, Math.min(s.length, Number.isFinite(pos) ? pos : 0));
1680
+ const r = Math.max(0, radius);
1681
+
1682
+ const start = Math.max(0, p - r);
1683
+ const end = Math.min(s.length, p + r);
1684
+
1685
+ const before = s.slice(start, p);
1686
+ const after = s.slice(p, end);
1687
+
1688
+ const left = start > 0 ? '...' : '';
1689
+ const right = end < s.length ? '...' : '';
1690
+
1691
+ return `${left}${before}<<HERE>>${after}${right}`;
1692
+ }
1693
+
1074
1694
  function sleep(ms: number): Promise<void> {
1075
1695
  return new Promise((resolve) => setTimeout(resolve, ms));
1076
1696
  }
1697
+
1698
+ /**
1699
+ * Sleep, but wake early if the signal is aborted.
1700
+ * (We do not throw here; the caller should check `signal.aborted` and act.)
1701
+ */
1702
+ function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
1703
+ if (!signal) {
1704
+ return sleep(ms);
1705
+ }
1706
+ if (signal.aborted) {
1707
+ return Promise.resolve();
1708
+ }
1709
+
1710
+ return new Promise((resolve) => {
1711
+ const t = setTimeout(() => {
1712
+ cleanup();
1713
+ resolve();
1714
+ }, ms);
1715
+
1716
+ const onAbort = () => {
1717
+ cleanup();
1718
+ resolve();
1719
+ };
1720
+
1721
+ const cleanup = () => {
1722
+ try {
1723
+ clearTimeout(t);
1724
+ } catch {
1725
+ // ignore
1726
+ }
1727
+ try {
1728
+ signal.removeEventListener?.('abort', onAbort as any);
1729
+ } catch {
1730
+ // ignore
1731
+ }
1732
+ };
1733
+
1734
+ try {
1735
+ signal.addEventListener?.('abort', onAbort as any, { once: true });
1736
+ } catch {
1737
+ // If addEventListener isn't available, fall back to plain sleep.
1738
+ }
1739
+ });
1740
+ }
1741
+
1742
+ function extractHttpStatus(error: unknown): number | undefined {
1743
+ if (!error || typeof error !== 'object') {
1744
+ return undefined;
1745
+ }
1746
+ const rec = error as Record<string, unknown>;
1747
+ const status = rec.status;
1748
+ if (typeof status === 'number' && Number.isFinite(status)) {
1749
+ return status;
1750
+ }
1751
+ const statusCode = rec.statusCode;
1752
+ if (typeof statusCode === 'number' && Number.isFinite(statusCode)) {
1753
+ return statusCode;
1754
+ }
1755
+ return undefined;
1756
+ }
1757
+
1758
+ function extractRequestId(error: unknown): string | undefined {
1759
+ if (!error || typeof error !== 'object') {
1760
+ return undefined;
1761
+ }
1762
+ const rec = error as Record<string, unknown>;
1763
+
1764
+ const direct = rec.request_id ?? rec.requestId;
1765
+ if (typeof direct === 'string' && direct.trim()) {
1766
+ return direct.trim();
1767
+ }
1768
+
1769
+ const headers = rec.headers as any;
1770
+ if (!headers) {
1771
+ return undefined;
1772
+ }
1773
+
1774
+ if (typeof headers.get === 'function') {
1775
+ const v = headers.get('x-request-id');
1776
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
1777
+ }
1778
+
1779
+ if (typeof headers === 'object' && !Array.isArray(headers)) {
1780
+ for (const k of Object.keys(headers)) {
1781
+ if (String(k).toLowerCase() !== 'x-request-id') {
1782
+ continue;
1783
+ }
1784
+ const v = (headers as any)[k];
1785
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
1786
+ }
1787
+ }
1788
+
1789
+ return undefined;
1790
+ }
1791
+
1792
+ function isRetryableHttpStatus(status: number | undefined): boolean {
1793
+ if (typeof status !== 'number') {
1794
+ return true;
1795
+ }
1796
+ if (status === 408 || status === 409 || status === 429) {
1797
+ return true;
1798
+ }
1799
+ if (status >= 500) {
1800
+ return true;
1801
+ }
1802
+ return false;
1803
+ }
1804
+
1805
+ function isAbortError(error: unknown): boolean {
1806
+ if (!error) {
1807
+ return false;
1808
+ }
1809
+
1810
+ // Most fetch implementations:
1811
+ // - error.name === 'AbortError'
1812
+ // - or error.code === 'ABORT_ERR'
1813
+ if (error instanceof Error) {
1814
+ const name = String(error.name ?? '').toLowerCase();
1815
+ if (name === 'aborterror') {
1816
+ return true;
1817
+ }
1818
+ const msg = String(error.message ?? '').toLowerCase();
1819
+ // Keep this conservative; don't treat every "abort" substring as abort.
1820
+ if (msg === 'aborted' || msg === 'request aborted') {
1821
+ return true;
1822
+ }
1823
+ }
1824
+
1825
+ if (typeof error === 'object') {
1826
+ const rec = error as Record<string, unknown>;
1827
+ const code = rec.code;
1828
+ if (typeof code === 'string' && code.toUpperCase() === 'ABORT_ERR') {
1829
+ return true;
1830
+ }
1831
+ }
1832
+
1833
+ return false;
1834
+ }
1835
+
1836
+ function safeErrorSummary(error: unknown): Record<string, unknown> {
1837
+ if (!error) {
1838
+ return { message: 'Unknown error' };
1839
+ }
1840
+
1841
+ const status = extractHttpStatus(error);
1842
+ const requestId = extractRequestId(error);
1843
+
1844
+ if (error instanceof OpenAiResponsesError) {
1845
+ return {
1846
+ name: error.name,
1847
+ message: error.message,
1848
+ code: error.code,
1849
+ details: error.details,
1850
+ status: typeof status === 'number' ? status : undefined,
1851
+ request_id: requestId,
1852
+ };
1853
+ }
1854
+
1855
+ if (error instanceof Error) {
1856
+ return {
1857
+ name: error.name,
1858
+ message: error.message,
1859
+ status: typeof status === 'number' ? status : undefined,
1860
+ request_id: requestId,
1861
+ };
1862
+ }
1863
+
1864
+ return {
1865
+ message: String(error),
1866
+ status: typeof status === 'number' ? status : undefined,
1867
+ request_id: requestId,
1868
+ };
1869
+ }