@independo/inderun-web 0.3.0-dev.12 → 0.3.0-dev.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,765 @@
1
+ import type { TaskRequest, TaskResult } from "@independo/inderun-contracts";
2
+ import {
3
+ createAuthError,
4
+ createInternal,
5
+ createRateLimited,
6
+ createTimeout,
7
+ createUnavailable
8
+ } from "../../core/errors.js";
9
+ import type { HostServices, HttpRequest } from "../../core/host.js";
10
+ import type {
11
+ ProviderAdapter,
12
+ ProviderDescriptor,
13
+ ProviderDynamicCapabilities,
14
+ ProviderStreamContext,
15
+ ProviderStreamEvent,
16
+ RunContext
17
+ } from "../../core/provider.js";
18
+ import { parseSseStream } from "../../core/sse.js";
19
+
20
+ /**
21
+ * The subset of the Responses API's server-sent event payloads this adapter
22
+ * reads. The event set is open and additive, so unknown fields and unknown
23
+ * types are ignored rather than treated as errors.
24
+ */
25
+ interface OpenAIStreamEvent {
26
+ type?: string;
27
+ delta?: string;
28
+ response?: OpenAIResponseBody;
29
+ error?: { message?: string; type?: string; code?: string };
30
+ /** Root-level failure fields, as carried by the standalone `error` event. */
31
+ message?: string;
32
+ code?: string;
33
+ }
34
+
35
+ /** Buffers a body stream to text, for error responses that are not event streams. */
36
+ async function drainToText(body: AsyncIterable<Uint8Array>): Promise<string> {
37
+ const decoder = new TextDecoder();
38
+ let text = "";
39
+ for await (const chunk of body) {
40
+ text += decoder.decode(chunk, { stream: true });
41
+ }
42
+ return text + decoder.decode();
43
+ }
44
+
45
+ export const DEFAULT_OPENAI_RESPONSES_ENDPOINT = "https://api.openai.com/v1/responses";
46
+
47
+ const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 3000;
48
+ const DEFAULT_HEALTH_CHECK_CACHE_MS = 5000;
49
+
50
+ /**
51
+ * Configuration for the OpenAI Responses provider.
52
+ */
53
+ export interface OpenAIProviderOptions {
54
+ /**
55
+ * Provider id exposed in telemetry and routing explanations.
56
+ */
57
+ id?: string;
58
+ /**
59
+ * OpenAI model id sent to the Responses API.
60
+ */
61
+ model: string;
62
+ /**
63
+ * Responses-compatible endpoint. Use a same-origin proxy endpoint in browser apps.
64
+ */
65
+ endpointUrl?: string;
66
+ /**
67
+ * Authentication mode. Use "none" for browser proxy endpoints that resolve
68
+ * OpenAI credentials server-side.
69
+ */
70
+ auth?: "authContextRef" | "none";
71
+ /**
72
+ * Default secure-storage slot used when the request does not provide authContextRef.
73
+ */
74
+ authContextRef?: string;
75
+ /**
76
+ * Optional HTTP timeout for the host transport attempt.
77
+ */
78
+ timeoutMs?: number;
79
+ /**
80
+ * Timeout for the endpoint reachability probe issued from `capabilities()`.
81
+ * Defaults to 3000ms. The OpenAI API has no dedicated health endpoint, so this
82
+ * is a cheap unauthenticated `GET` against the configured endpoint.
83
+ */
84
+ healthCheckTimeoutMs?: number;
85
+ /**
86
+ * How long a reachability probe result is cached before `capabilities()` re-probes.
87
+ * Defaults to 5000ms. Avoids re-probing on every routing decision and UI capability check.
88
+ */
89
+ healthCheckCacheMs?: number;
90
+ }
91
+
92
+ type OpenAIResponseBody = {
93
+ output_text?: unknown;
94
+ output?: unknown;
95
+ status?: unknown;
96
+ usage?: {
97
+ input_tokens?: unknown;
98
+ output_tokens?: unknown;
99
+ total_tokens?: unknown;
100
+ };
101
+ incomplete_details?: {
102
+ reason?: unknown;
103
+ };
104
+ error?: {
105
+ message?: string;
106
+ type?: string;
107
+ code?: string;
108
+ };
109
+ };
110
+
111
+ type OpenAIErrorBody = {
112
+ error?: {
113
+ message?: unknown;
114
+ type?: unknown;
115
+ code?: unknown;
116
+ };
117
+ };
118
+
119
+ /**
120
+ * Cloud provider adapter that executes Mode-1 text-to-text requests through the OpenAI Responses API.
121
+ *
122
+ * Browser applications should prefer a same-origin proxy endpoint with `auth: "none"` so OpenAI credentials
123
+ * stay server-side. Direct OpenAI authentication is available through `authContextRef` and `SecureStorageService`
124
+ * for controlled environments.
125
+ */
126
+ export class OpenAIResponsesProvider implements ProviderAdapter {
127
+ private readonly id: string;
128
+ private readonly endpointUrl: string;
129
+ private readonly auth: "authContextRef" | "none";
130
+ private cachedHealth?: { result: ProviderDynamicCapabilities; checkedAt: number };
131
+
132
+ /**
133
+ * Creates an OpenAI Responses provider.
134
+ * @param options - Provider configuration, including required OpenAI model id.
135
+ */
136
+ constructor(private readonly options: OpenAIProviderOptions) {
137
+ this.id = options.id ?? "openai";
138
+ this.endpointUrl = options.endpointUrl ?? DEFAULT_OPENAI_RESPONSES_ENDPOINT;
139
+ this.auth = options.auth ?? "authContextRef";
140
+ }
141
+
142
+ /**
143
+ * Returns static metadata used by the router for deterministic provider selection.
144
+ */
145
+ describe(): ProviderDescriptor {
146
+ return {
147
+ id: this.id,
148
+ type: "cloud",
149
+ transport: "http",
150
+ streamingStyle: "tokens",
151
+ supports: {
152
+ run: true,
153
+ streaming: true,
154
+ realtime: false,
155
+ tools: false,
156
+ reasoningEvents: false,
157
+ structuredOutput: false,
158
+ multimodal: false
159
+ },
160
+ cancel: "hard",
161
+ tasks: ["text_to_text"],
162
+ privacy: {
163
+ dataLeavesDevice: true
164
+ }
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Reports dynamic provider availability for the current host.
170
+ *
171
+ * After the static host-service checks pass, this probes endpoint reachability with a
172
+ * cheap unauthenticated `GET` against the configured endpoint (the OpenAI API has no
173
+ * dedicated health endpoint). The result is cached for `healthCheckCacheMs` so routing
174
+ * decisions and repeated UI capability checks don't re-probe on every call.
175
+ * @param host - Host services available to the engine.
176
+ */
177
+ async capabilities(host: HostServices): Promise<ProviderDynamicCapabilities> {
178
+ if (!host.httpClient) {
179
+ return {
180
+ available: false,
181
+ reason: "OpenAI Responses provider requires an HttpClientService."
182
+ };
183
+ }
184
+
185
+ if (this.auth !== "none" && !host.secureStorage) {
186
+ return {
187
+ available: false,
188
+ reason: "OpenAI Responses provider requires a SecureStorageService when auth is enabled."
189
+ };
190
+ }
191
+
192
+ const reachability = await this.checkEndpointReachable(host.httpClient, host.clock);
193
+ if (host.streamingHttpClient) {
194
+ return reachability;
195
+ }
196
+
197
+ // Mode 1 still works without it; only streaming is taken away, and the
198
+ // planner turns this into an inspectable `streaming_unavailable` rejection
199
+ // rather than an unexplained routing failure.
200
+ return {
201
+ ...reachability,
202
+ streamingAvailable: false,
203
+ streamingUnavailableReason:
204
+ "Host does not provide an HttpStreamingClientService, which OpenAI streaming requires."
205
+ };
206
+ }
207
+
208
+ private async checkEndpointReachable(
209
+ httpClient: NonNullable<HostServices["httpClient"]>,
210
+ clock: HostServices["clock"]
211
+ ): Promise<ProviderDynamicCapabilities> {
212
+ const now = clock ? clock.now() : Date.now();
213
+ const cacheMs = this.options.healthCheckCacheMs ?? DEFAULT_HEALTH_CHECK_CACHE_MS;
214
+
215
+ if (this.cachedHealth && now - this.cachedHealth.checkedAt < cacheMs) {
216
+ return this.cachedHealth.result;
217
+ }
218
+
219
+ let result: ProviderDynamicCapabilities;
220
+ try {
221
+ const response = await httpClient.send({
222
+ method: "GET",
223
+ url: this.endpointUrl,
224
+ timeoutMs: this.options.healthCheckTimeoutMs ?? DEFAULT_HEALTH_CHECK_TIMEOUT_MS
225
+ });
226
+
227
+ result =
228
+ response.status >= 500
229
+ ? {
230
+ available: false,
231
+ reason: `OpenAI Responses endpoint returned HTTP ${response.status}.`
232
+ }
233
+ : { available: true };
234
+ } catch (err) {
235
+ result = {
236
+ available: false,
237
+ reason: isAbortError(err)
238
+ ? "OpenAI Responses endpoint health check timed out."
239
+ : "OpenAI Responses endpoint is unreachable."
240
+ };
241
+ }
242
+
243
+ this.cachedHealth = { result, checkedAt: now };
244
+ return result;
245
+ }
246
+
247
+ /**
248
+ * Executes a normalized text-to-text task using the OpenAI Responses API.
249
+ * @param request - Canonical IndeRun task request.
250
+ * @param context - Engine execution context containing run id and host services.
251
+ * @returns Normalized IndeRun task result.
252
+ * @throws IndeRunException mapped to the standard error taxonomy.
253
+ */
254
+ async run(request: TaskRequest, context: RunContext): Promise<TaskResult> {
255
+ const httpClient = context.hostServices.httpClient;
256
+ if (!httpClient) {
257
+ throw createUnavailable("OpenAI Responses provider requires an HTTP client.", {
258
+ providerId: this.id,
259
+ runId: context.runId
260
+ });
261
+ }
262
+
263
+ const headers = await this.resolveHeaders(request, context);
264
+
265
+ let response;
266
+ try {
267
+ const httpRequest: HttpRequest = {
268
+ method: "POST",
269
+ url: this.endpointUrl,
270
+ headers,
271
+ body: JSON.stringify(this.createRequestBody(request))
272
+ };
273
+
274
+ if (this.options.timeoutMs !== undefined) {
275
+ httpRequest.timeoutMs = this.options.timeoutMs;
276
+ }
277
+
278
+ response = await httpClient.send(httpRequest);
279
+ } catch (err) {
280
+ if (isAbortError(err)) {
281
+ throw createTimeout("OpenAI Responses request timed out.", {
282
+ providerId: this.id,
283
+ runId: context.runId
284
+ });
285
+ }
286
+
287
+ throw createUnavailable("OpenAI Responses request failed before a response was received.", {
288
+ providerId: this.id,
289
+ runId: context.runId,
290
+ details: { originalError: getErrorSummary(err) }
291
+ });
292
+ }
293
+
294
+ if (response.status < 200 || response.status >= 300) {
295
+ throw this.mapHttpError(
296
+ response.status,
297
+ response.statusText,
298
+ response.headers,
299
+ response.body,
300
+ context
301
+ );
302
+ }
303
+
304
+ const responseBody = parseJson<OpenAIResponseBody>(response.body);
305
+ const outputText = extractOutputText(responseBody);
306
+ if (outputText === undefined) {
307
+ throw createInternal("OpenAI Responses payload did not contain text output.", {
308
+ providerId: this.id,
309
+ runId: context.runId
310
+ });
311
+ }
312
+
313
+ const result: TaskResult = {
314
+ schemaVersion: "1.0",
315
+ runId: context.runId,
316
+ output: {
317
+ type: "text",
318
+ text: outputText
319
+ },
320
+ finishReason: getFinishReason(responseBody),
321
+ telemetry: {
322
+ providerUsed: this.id,
323
+ totalMs: 0
324
+ }
325
+ };
326
+
327
+ const usage = getUsage(responseBody);
328
+ if (usage) {
329
+ result.usage = usage;
330
+ }
331
+
332
+ return result;
333
+ }
334
+
335
+ /**
336
+ * Executes a normalized text-to-text task in Mode 2 against the OpenAI
337
+ * Responses API's server-sent event stream.
338
+ *
339
+ * The request is the Mode 1 body plus `"stream": true`, so both modes stay a
340
+ * single code path up to the transport. The response head is classified
341
+ * before the body is read: a non-2xx is drained to text and run through the
342
+ * same `mapHttpError` as Mode 1, because a 429 must surface as RateLimited
343
+ * rather than as a malformed event stream.
344
+ *
345
+ * Event mapping, from the Responses API's typed SSE events to the canonical
346
+ * provider vocabulary:
347
+ *
348
+ * - `response.output_text.delta` becomes a delta;
349
+ * - `response.completed` and `response.incomplete` become the terminal done,
350
+ * with `finalText`, `usage`, and `finishReason` read off the embedded
351
+ * response object by the same helpers Mode 1 uses — it has the same shape;
352
+ * - `response.failed` and `error` become a terminal error;
353
+ * - every other event type is ignored, since the set is open and additive.
354
+ *
355
+ * @param request - Canonical IndeRun task request.
356
+ * @param context - Engine stream context: run id, host services, abort signal.
357
+ */
358
+ async *stream(
359
+ request: TaskRequest,
360
+ context: ProviderStreamContext
361
+ ): AsyncGenerator<ProviderStreamEvent> {
362
+ const streamingClient = context.hostServices.streamingHttpClient;
363
+ if (!streamingClient) {
364
+ throw createUnavailable(
365
+ "OpenAI Responses streaming requires an HttpStreamingClientService.",
366
+ {
367
+ providerId: this.id,
368
+ runId: context.runId
369
+ }
370
+ );
371
+ }
372
+
373
+ const headers = await this.resolveHeaders(request, context);
374
+ headers.Accept = "text/event-stream";
375
+
376
+ let response;
377
+ try {
378
+ const httpRequest: HttpRequest = {
379
+ method: "POST",
380
+ url: this.endpointUrl,
381
+ headers,
382
+ body: JSON.stringify(this.createRequestBody(request, true))
383
+ };
384
+
385
+ if (this.options.timeoutMs !== undefined) {
386
+ httpRequest.timeoutMs = this.options.timeoutMs;
387
+ }
388
+
389
+ response = await streamingClient.stream(httpRequest, context.signal);
390
+ } catch (err) {
391
+ if (isAbortError(err)) {
392
+ throw createTimeout("OpenAI Responses stream timed out before the first byte.", {
393
+ providerId: this.id,
394
+ runId: context.runId
395
+ });
396
+ }
397
+
398
+ throw createUnavailable("OpenAI Responses stream failed before a response was received.", {
399
+ providerId: this.id,
400
+ runId: context.runId,
401
+ details: { originalError: getErrorSummary(err) }
402
+ });
403
+ }
404
+
405
+ if (response.status < 200 || response.status >= 300) {
406
+ throw this.mapHttpError(
407
+ response.status,
408
+ response.statusText,
409
+ response.headers,
410
+ await drainToText(response.body),
411
+ context
412
+ );
413
+ }
414
+
415
+ for await (const sseEvent of parseSseStream(response.body)) {
416
+ if (sseEvent.data === "[DONE]") {
417
+ continue;
418
+ }
419
+
420
+ const parsed = parseJson<OpenAIStreamEvent>(sseEvent.data);
421
+ // The `event:` line and the payload's own `type` carry the same value;
422
+ // prefer the payload so a proxy that drops event names still works.
423
+ const type = parsed.type ?? sseEvent.event;
424
+
425
+ if (type === "response.output_text.delta") {
426
+ if (parsed.delta) {
427
+ yield { kind: "delta", text: parsed.delta };
428
+ }
429
+ continue;
430
+ }
431
+
432
+ if (type === "response.completed" || type === "response.incomplete") {
433
+ const body = parsed.response ?? {};
434
+ const done: ProviderStreamEvent = {
435
+ kind: "done",
436
+ finalText: extractOutputText(body) ?? "",
437
+ finishReason: getFinishReason(body)
438
+ };
439
+ const usage = getUsage(body);
440
+ if (usage) {
441
+ done.usage = usage;
442
+ }
443
+ yield done;
444
+ return;
445
+ }
446
+
447
+ if (type === "response.failed" || type === "error") {
448
+ yield { kind: "error", error: this.mapStreamError(parsed, context) };
449
+ return;
450
+ }
451
+ }
452
+
453
+ // Falling out of the loop means the stream ended without a terminal event.
454
+ // The engine reports that as a provider fault; inventing a completion from
455
+ // whatever deltas happened to arrive would hide a truncated response.
456
+ }
457
+
458
+ /**
459
+ * Maps a `response.failed` / `error` stream event onto the error taxonomy.
460
+ * These arrive over an HTTP 200 body, so there is no status code to classify
461
+ * from; OpenAI reports rate limiting and auth failures here with the same
462
+ * `code` values it uses in unary error bodies.
463
+ *
464
+ * Two payload shapes exist and both are accepted: the standalone `error`
465
+ * event carries `code`/`message` at its root, while `response.failed` nests
466
+ * them under `response.error`. The root `type` is the event name rather than
467
+ * an error type, so it is never read as one.
468
+ */
469
+ private mapStreamError(event: OpenAIStreamEvent, context: ProviderStreamContext): unknown {
470
+ const nested = event.error ?? event.response?.error;
471
+ const message =
472
+ nested?.message ?? event.message ?? "OpenAI Responses stream reported a failure.";
473
+ const code = nested?.code ?? event.code;
474
+ const details = { errorType: nested?.type, errorCode: code };
475
+ const params = { providerId: this.id, runId: context.runId, details };
476
+
477
+ switch (code) {
478
+ case "rate_limit_exceeded":
479
+ return createRateLimited(message, { ...params, retryable: true });
480
+ case "invalid_api_key":
481
+ case "authentication_error":
482
+ return createAuthError(message, params);
483
+ default:
484
+ return createUnavailable(message, { ...params, retryable: true });
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Resolves the request headers, including the bearer token behind
490
+ * `authContextRef`. Shared by `run` and `stream`: the credential never travels
491
+ * in the request payload, only the slot id does, so both modes must resolve it
492
+ * the same way.
493
+ */
494
+ private async resolveHeaders(
495
+ request: TaskRequest,
496
+ context: RunContext
497
+ ): Promise<Record<string, string>> {
498
+ const headers: Record<string, string> = {
499
+ "Content-Type": "application/json"
500
+ };
501
+
502
+ if (this.auth !== "authContextRef") {
503
+ return headers;
504
+ }
505
+
506
+ const slotId = request.authContextRef ?? this.options.authContextRef;
507
+ if (!slotId) {
508
+ throw createAuthError("OpenAI Responses provider requires authContextRef.", {
509
+ providerId: this.id,
510
+ runId: context.runId
511
+ });
512
+ }
513
+
514
+ const secureStorage = context.hostServices.secureStorage;
515
+ if (!secureStorage) {
516
+ throw createAuthError(
517
+ "OpenAI Responses provider requires a SecureStorageService when auth is enabled.",
518
+ {
519
+ providerId: this.id,
520
+ runId: context.runId
521
+ }
522
+ );
523
+ }
524
+
525
+ const secret = await secureStorage.getSecret(slotId);
526
+ if (!secret) {
527
+ throw createAuthError(`No OpenAI credential found for authContextRef '${slotId}'.`, {
528
+ providerId: this.id,
529
+ runId: context.runId
530
+ });
531
+ }
532
+
533
+ headers.Authorization = `Bearer ${secret}`;
534
+ return headers;
535
+ }
536
+
537
+ private createRequestBody(request: TaskRequest, stream = false): Record<string, unknown> {
538
+ const body: Record<string, unknown> = {
539
+ model: this.options.model,
540
+ input: createInput(request)
541
+ };
542
+
543
+ if (stream) {
544
+ body.stream = true;
545
+ }
546
+
547
+ const generation = request.generation;
548
+ if (generation?.maxOutputTokens !== undefined) {
549
+ body.max_output_tokens = generation.maxOutputTokens;
550
+ }
551
+ if (generation?.temperature !== undefined) {
552
+ body.temperature = generation.temperature;
553
+ }
554
+ if (generation?.topP !== undefined) {
555
+ body.top_p = generation.topP;
556
+ }
557
+ if (generation?.stop !== undefined) {
558
+ body.stop = generation.stop;
559
+ }
560
+
561
+ return body;
562
+ }
563
+
564
+ /**
565
+ * Maps an OpenAI HTTP error response onto the IndeRun error taxonomy:
566
+ * - `401` / `403` → `AuthError`
567
+ * - `429` → `RateLimited` (honors `Retry-After` when present)
568
+ * - `408` / `504` → `Timeout`
569
+ * - `409` / `5xx` → `Unavailable`
570
+ * - any other non-2xx status → `Internal`
571
+ */
572
+ private mapHttpError(
573
+ status: number,
574
+ statusText: string,
575
+ headers: Record<string, string>,
576
+ body: string,
577
+ context: RunContext
578
+ ): Error {
579
+ const error = parseJson<OpenAIErrorBody>(body);
580
+ const message = getOpenAIErrorMessage(error, status, statusText);
581
+ const details = {
582
+ status,
583
+ statusText,
584
+ errorType: getString(error.error?.type),
585
+ errorCode: getString(error.error?.code)
586
+ };
587
+
588
+ if (status === 401 || status === 403) {
589
+ return createAuthError(message, {
590
+ providerId: this.id,
591
+ runId: context.runId,
592
+ details
593
+ });
594
+ }
595
+
596
+ if (status === 429) {
597
+ const retryAfterMs = parseRetryAfterMs(headers);
598
+ const params = {
599
+ providerId: this.id,
600
+ runId: context.runId,
601
+ retryable: true,
602
+ details
603
+ };
604
+
605
+ if (retryAfterMs !== undefined) {
606
+ return createRateLimited(message, {
607
+ ...params,
608
+ retryAfterMs
609
+ });
610
+ }
611
+
612
+ return createRateLimited(message, params);
613
+ }
614
+
615
+ if (status === 408 || status === 504) {
616
+ return createTimeout(message, {
617
+ providerId: this.id,
618
+ runId: context.runId,
619
+ retryable: true,
620
+ details
621
+ });
622
+ }
623
+
624
+ if (status === 409 || status >= 500) {
625
+ return createUnavailable(message, {
626
+ providerId: this.id,
627
+ runId: context.runId,
628
+ retryable: true,
629
+ details
630
+ });
631
+ }
632
+
633
+ return createInternal(message, {
634
+ providerId: this.id,
635
+ runId: context.runId,
636
+ details
637
+ });
638
+ }
639
+ }
640
+
641
+ function createInput(request: TaskRequest): string | Array<Record<string, string>> {
642
+ if (request.messages && request.messages.length > 0) {
643
+ return request.messages.map((message) => ({
644
+ role: message.role === "system" ? "developer" : message.role,
645
+ content: message.content
646
+ }));
647
+ }
648
+
649
+ return request.prompt ?? "";
650
+ }
651
+
652
+ function extractOutputText(response: OpenAIResponseBody): string | undefined {
653
+ if (typeof response.output_text === "string") {
654
+ return response.output_text;
655
+ }
656
+
657
+ if (!Array.isArray(response.output)) {
658
+ return undefined;
659
+ }
660
+
661
+ const textParts: string[] = [];
662
+ for (const item of response.output) {
663
+ if (!isRecord(item) || !Array.isArray(item.content)) {
664
+ continue;
665
+ }
666
+
667
+ for (const contentItem of item.content) {
668
+ if (
669
+ isRecord(contentItem) &&
670
+ contentItem.type === "output_text" &&
671
+ typeof contentItem.text === "string"
672
+ ) {
673
+ textParts.push(contentItem.text);
674
+ }
675
+ }
676
+ }
677
+
678
+ return textParts.length > 0 ? textParts.join("") : undefined;
679
+ }
680
+
681
+ function getFinishReason(response: OpenAIResponseBody): TaskResult["finishReason"] {
682
+ if (response.status === "incomplete") {
683
+ return response.incomplete_details?.reason === "max_output_tokens" ? "length" : "error";
684
+ }
685
+
686
+ return "stop";
687
+ }
688
+
689
+ function getUsage(response: OpenAIResponseBody): TaskResult["usage"] | undefined {
690
+ const usage: TaskResult["usage"] = {};
691
+
692
+ if (typeof response.usage?.input_tokens === "number") {
693
+ usage.inputTokens = response.usage.input_tokens;
694
+ }
695
+ if (typeof response.usage?.output_tokens === "number") {
696
+ usage.outputTokens = response.usage.output_tokens;
697
+ }
698
+ if (typeof response.usage?.total_tokens === "number") {
699
+ usage.totalTokens = response.usage.total_tokens;
700
+ }
701
+
702
+ return Object.keys(usage).length > 0 ? usage : undefined;
703
+ }
704
+
705
+ function parseJson<T>(value: string): T {
706
+ try {
707
+ return JSON.parse(value) as T;
708
+ } catch {
709
+ return {} as T;
710
+ }
711
+ }
712
+
713
+ function getOpenAIErrorMessage(
714
+ response: OpenAIErrorBody,
715
+ status: number,
716
+ statusText: string
717
+ ): string {
718
+ if (typeof response.error?.message === "string" && response.error.message.length > 0) {
719
+ return response.error.message;
720
+ }
721
+
722
+ return `OpenAI Responses request failed with HTTP ${status} ${statusText}.`;
723
+ }
724
+
725
+ function parseRetryAfterMs(headers: Record<string, string>): number | undefined {
726
+ const raw = headers["retry-after"] ?? headers["Retry-After"];
727
+ if (!raw) {
728
+ return undefined;
729
+ }
730
+
731
+ const seconds = Number(raw);
732
+ if (Number.isFinite(seconds)) {
733
+ return Math.max(0, seconds * 1000);
734
+ }
735
+
736
+ const dateMs = Date.parse(raw);
737
+ if (Number.isFinite(dateMs)) {
738
+ return Math.max(0, dateMs - Date.now());
739
+ }
740
+
741
+ return undefined;
742
+ }
743
+
744
+ function isAbortError(error: unknown): boolean {
745
+ return isRecord(error) && getString(error.name) === "AbortError";
746
+ }
747
+
748
+ function getErrorSummary(error: unknown): Record<string, string> {
749
+ if (error instanceof Error) {
750
+ return {
751
+ name: error.name,
752
+ message: error.message
753
+ };
754
+ }
755
+
756
+ return { message: String(error) };
757
+ }
758
+
759
+ function isRecord(value: unknown): value is Record<string, unknown> {
760
+ return Boolean(value) && typeof value === "object";
761
+ }
762
+
763
+ function getString(value: unknown): string | undefined {
764
+ return typeof value === "string" ? value : undefined;
765
+ }