@independo/inderun-web 0.3.0-dev.9 → 0.3.1-dev.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/README.md +12 -10
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -2
- package/dist/onnx.d.ts +21 -0
- package/dist/onnx.d.ts.map +1 -1
- package/dist/onnx.js +15 -0
- package/dist/openai.d.ts +7 -0
- package/dist/openai.d.ts.map +1 -1
- package/dist/openai.js +7 -0
- package/dist/system-model.d.ts +13 -0
- package/dist/system-model.d.ts.map +1 -1
- package/dist/system-model.js +13 -0
- package/package.json +22 -8
- package/src/core/browser-host.ts +339 -0
- package/src/core/engine.ts +540 -0
- package/src/core/errors.ts +220 -0
- package/src/core/event-gate.ts +71 -0
- package/src/core/generated/inderun-api.ts +19 -0
- package/src/core/host.ts +15 -0
- package/src/core/provider.ts +231 -0
- package/src/core/registry.ts +48 -0
- package/src/core/route-planner.ts +261 -0
- package/src/core/router.ts +149 -0
- package/src/core/sse.ts +134 -0
- package/src/core/telemetry.ts +20 -0
- package/src/index.ts +147 -0
- package/src/onnx.ts +57 -0
- package/src/openai.ts +21 -0
- package/src/providers/onnx/provider.ts +300 -0
- package/src/providers/onnx/runtime.ts +188 -0
- package/src/providers/onnx/transformers-runtime.ts +345 -0
- package/src/providers/openai/provider.ts +765 -0
- package/src/providers/system-model/chrome-runtime.ts +221 -0
- package/src/providers/system-model/provider.ts +260 -0
- package/src/providers/system-model/runtime.ts +202 -0
- package/src/system-model.ts +49 -0
- package/src/web-sdk.ts +114 -0
|
@@ -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
|
+
}
|