@fevex/openai 0.1.0-alpha.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 ADDED
@@ -0,0 +1,73 @@
1
+ # @fevex/openai
2
+
3
+ Official OpenAI `ModelGateway` adapter for Fevex.
4
+
5
+ ```bash
6
+ npm install @fevex/core @fevex/openai
7
+ ```
8
+
9
+ ```ts
10
+ import { createOpenAI } from '@fevex/openai';
11
+
12
+ const model = createOpenAI({
13
+ apiKey: process.env.OPENAI_API_KEY!,
14
+ organization: process.env.OPENAI_ORG_ID,
15
+ project: process.env.OPENAI_PROJECT_ID,
16
+ schemaPolicy: 'strict',
17
+ })('gpt-5.6');
18
+ ```
19
+
20
+ The adapter uses native `fetch` and implements only the Fevex model contract.
21
+ Runtime behavior such as tool execution, validation, events and retries belongs
22
+ to `@fevex/core`. Returned gateways include `metadata: { provider: "openai",
23
+ model: modelId }` for traces, eval cost calculators and OpenTelemetry export.
24
+
25
+ ## Text, tools and continuation
26
+
27
+ Without `outputSchema`, OpenAI text is returned as the exact string supplied by
28
+ the Responses API, even when it looks like JSON. With `outputSchema`, the
29
+ adapter requires valid JSON before Fevex performs local schema validation.
30
+ Refusals, incomplete responses and malformed function calls fail explicitly.
31
+
32
+ During tool loops the adapter stores the original response items, including
33
+ [reasoning items required by Responses API](https://platform.openai.com/docs/api-reference/responses-streaming/response/refusal?lang=python),
34
+ in opaque `providerState`. Fevex returns that state to the next model step
35
+ automatically. Direct `ModelGateway` consumers must do the same; the state is
36
+ run-local and must not be inspected. The adapter's `stateCodec` lets Fevex
37
+ persist it only inside a private durable checkpoint.
38
+
39
+ Core reasoning efforts are forwarded to `reasoning.effort`;
40
+ `provider-default` leaves provider options untouched. Model-specific effort
41
+ support remains an OpenAI capability and an unsupported value is reported by
42
+ the API. Provider-only settings can be supplied through
43
+ `modelOptions.reasoning`.
44
+
45
+ The adapter always uses the Responses streaming API. It emits only
46
+ `response.output_text.delta` as visible output and builds the terminal result
47
+ from `response.completed`; reasoning and partial function arguments remain
48
+ private.
49
+
50
+ Tool names and `schemaName` must match `[A-Za-z0-9_-]{1,64}`. Fevex-owned
51
+ fields such as model, input, tools, tool choice, schema format, parallel calls
52
+ and output caps override conflicting `modelOptions`. Background provider jobs
53
+ and built-in tools remain outside this gateway.
54
+
55
+ ## Schema policy
56
+
57
+ `schemaPolicy` defaults to `"strict"`. Before sending a request, the adapter
58
+ checks tool inputs and final outputs against OpenAI's documented
59
+ [Structured Outputs subset](https://developers.openai.com/api/docs/guides/structured-outputs).
60
+ Every schema must have an object root; every object must require all its
61
+ properties and set `additionalProperties: false`. Unsupported keywords and
62
+ documented size limits throw `OpenAIError` with
63
+ `code: "PROVIDER_SCHEMA_UNSUPPORTED"` before `fetch` runs.
64
+
65
+ Set `schemaPolicy: "best-effort"` to send tools with `strict: false`. Object
66
+ outputs use JSON mode and other JSON values use a schema instruction. Fevex
67
+ still validates the returned value locally, but OpenAI does not guarantee schema
68
+ adherence in this mode, so the caller may need to retry an invalid result.
69
+
70
+ | Policy | Tool input | Final output |
71
+ | --- | --- | --- |
72
+ | `strict` | OpenAI strict function schema | Structured Outputs |
73
+ | `best-effort` | Non-strict function schema | JSON mode or JSON instruction |
@@ -0,0 +1,33 @@
1
+ import { ModelGateway, PROVIDER_REASONING_UNSUPPORTED, PROVIDER_SCHEMA_UNSUPPORTED } from "@fevex/core";
2
+ //#region src/config.d.ts
3
+ type OpenAISchemaPolicy = 'strict' | 'best-effort';
4
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
5
+ interface OpenAIConfig {
6
+ apiKey: string;
7
+ baseURL?: string;
8
+ organization?: string;
9
+ project?: string;
10
+ schemaName?: string;
11
+ schemaPolicy?: OpenAISchemaPolicy;
12
+ fetch?: FetchLike;
13
+ }
14
+ //#endregion
15
+ //#region src/openai-error.d.ts
16
+ type OpenAIErrorCode = typeof PROVIDER_SCHEMA_UNSUPPORTED | typeof PROVIDER_REASONING_UNSUPPORTED;
17
+ interface OpenAIErrorOptions {
18
+ status?: number;
19
+ requestId?: string;
20
+ code?: OpenAIErrorCode;
21
+ cause?: unknown;
22
+ }
23
+ declare class OpenAIError extends Error {
24
+ status?: number;
25
+ requestId?: string;
26
+ code?: OpenAIErrorCode;
27
+ constructor(message: string, options?: OpenAIErrorOptions);
28
+ }
29
+ //#endregion
30
+ //#region src/index.d.ts
31
+ declare function createOpenAI(config: OpenAIConfig): (modelId: string) => ModelGateway;
32
+ //#endregion
33
+ export { type OpenAIConfig, OpenAIError, type OpenAISchemaPolicy, createOpenAI };
package/dist/index.mjs ADDED
@@ -0,0 +1,742 @@
1
+ import { PROVIDER_SCHEMA_UNSUPPORTED } from "@fevex/core";
2
+ //#region src/config.ts
3
+ function resolveOpenAIConfig(config) {
4
+ if (!isRecord$3(config)) throw new TypeError("OpenAI config must be an object");
5
+ if (typeof config.apiKey !== "string" || !config.apiKey.trim()) throw new TypeError("OpenAI apiKey cannot be empty");
6
+ assertOptionalString(config.baseURL, "baseURL");
7
+ assertOptionalString(config.organization, "organization");
8
+ assertOptionalString(config.project, "project");
9
+ if (config.schemaName !== void 0 && !isProviderName$1(config.schemaName)) throw new TypeError("OpenAI schemaName must match [A-Za-z0-9_-]{1,64}");
10
+ if (config.schemaPolicy !== void 0 && config.schemaPolicy !== "strict" && config.schemaPolicy !== "best-effort") throw new TypeError("OpenAI schemaPolicy must be \"strict\" or \"best-effort\"");
11
+ const requestFetch = config.fetch ?? globalThis.fetch;
12
+ if (typeof requestFetch !== "function") throw new TypeError("OpenAI adapter requires fetch");
13
+ return {
14
+ apiKey: config.apiKey,
15
+ baseURL: config.baseURL,
16
+ organization: config.organization,
17
+ project: config.project,
18
+ schemaName: config.schemaName,
19
+ schemaPolicy: config.schemaPolicy ?? "strict",
20
+ fetch: requestFetch
21
+ };
22
+ }
23
+ function assertOptionalString(value, name) {
24
+ if (value !== void 0 && (typeof value !== "string" || !value.trim())) throw new TypeError(`OpenAI ${name} must be a non-empty string`);
25
+ }
26
+ function isProviderName$1(value) {
27
+ return typeof value === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(value);
28
+ }
29
+ function isRecord$3(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+ //#endregion
33
+ //#region src/openai-error.ts
34
+ var OpenAIError = class extends Error {
35
+ status;
36
+ requestId;
37
+ code;
38
+ constructor(message, options = {}) {
39
+ super(message, { cause: options.cause });
40
+ this.name = "OpenAIError";
41
+ this.status = options.status;
42
+ this.requestId = options.requestId;
43
+ this.code = options.code;
44
+ }
45
+ };
46
+ //#endregion
47
+ //#region src/internal/provider-state.ts
48
+ const stateBrand = Symbol("openai-provider-state");
49
+ function readOpenAIProviderState(value, modelId) {
50
+ if (value === void 0) return void 0;
51
+ if (!isOpenAIProviderState(value) || value.modelId !== modelId) throw new OpenAIError("OpenAI providerState is invalid for this model");
52
+ return value;
53
+ }
54
+ function appendOpenAIProviderState(previous, modelId, output, toolCallIds) {
55
+ return {
56
+ [stateBrand]: true,
57
+ modelId,
58
+ turns: [...previous?.turns ?? [], {
59
+ toolCallIds: [...toolCallIds],
60
+ output: [...output]
61
+ }]
62
+ };
63
+ }
64
+ function serializeOpenAIProviderState(value, modelId) {
65
+ const state = readOpenAIProviderState(value, modelId);
66
+ if (!state) throw new OpenAIError("OpenAI providerState is required");
67
+ return JSON.parse(JSON.stringify({
68
+ modelId: state.modelId,
69
+ turns: state.turns
70
+ }));
71
+ }
72
+ function restoreOpenAIProviderState(value, modelId) {
73
+ if (typeof value !== "object" || value === null || Array.isArray(value) || value.modelId !== modelId || !Array.isArray(value.turns)) throw new OpenAIError("OpenAI serialized providerState is invalid for this model");
74
+ const turns = value.turns.map((turn) => {
75
+ if (typeof turn !== "object" || turn === null || Array.isArray(turn) || !Array.isArray(turn.toolCallIds) || !turn.toolCallIds.every((id) => typeof id === "string" && id.length > 0) || !Array.isArray(turn.output)) throw new OpenAIError("OpenAI serialized providerState is invalid for this model");
76
+ return {
77
+ toolCallIds: [...turn.toolCallIds],
78
+ output: structuredClone(turn.output)
79
+ };
80
+ });
81
+ return {
82
+ [stateBrand]: true,
83
+ modelId,
84
+ turns
85
+ };
86
+ }
87
+ function isOpenAIProviderState(value) {
88
+ if (typeof value !== "object" || value === null) return false;
89
+ if (value[stateBrand] !== true || typeof value.modelId !== "string" || !Array.isArray(value.turns)) return false;
90
+ return value.turns.every((turn) => typeof turn === "object" && turn !== null && Array.isArray(turn.toolCallIds) && turn.toolCallIds.every((id) => typeof id === "string" && id.length > 0) && Array.isArray(turn.output));
91
+ }
92
+ //#endregion
93
+ //#region src/internal/provider-schema.ts
94
+ const supportedTypes = /* @__PURE__ */ new Set([
95
+ "string",
96
+ "number",
97
+ "boolean",
98
+ "integer",
99
+ "object",
100
+ "array",
101
+ "null"
102
+ ]);
103
+ const supportedFormats = /* @__PURE__ */ new Set([
104
+ "date-time",
105
+ "time",
106
+ "date",
107
+ "duration",
108
+ "email",
109
+ "hostname",
110
+ "ipv4",
111
+ "ipv6",
112
+ "uuid"
113
+ ]);
114
+ const supportedKeywords = /* @__PURE__ */ new Set([
115
+ "$schema",
116
+ "$id",
117
+ "$ref",
118
+ "$defs",
119
+ "title",
120
+ "description",
121
+ "type",
122
+ "properties",
123
+ "required",
124
+ "additionalProperties",
125
+ "items",
126
+ "enum",
127
+ "const",
128
+ "anyOf",
129
+ "format",
130
+ "pattern",
131
+ "minimum",
132
+ "maximum",
133
+ "exclusiveMinimum",
134
+ "exclusiveMaximum",
135
+ "multipleOf",
136
+ "minItems",
137
+ "maxItems"
138
+ ]);
139
+ function findOpenAISchemaIssue(schema) {
140
+ if (schema.type !== "object" || schema.anyOf !== void 0) return "$: root must be an object and must not use anyOf";
141
+ const state = {
142
+ propertyCount: 0,
143
+ enumCount: 0,
144
+ stringLength: 0
145
+ };
146
+ const issue = visitSchema(schema, "$", 0, state);
147
+ if (issue) return issue;
148
+ if (state.propertyCount > 5e3) return "$: schema exceeds OpenAI limit of 5000 object properties";
149
+ if (state.enumCount > 1e3) return "$: schema exceeds OpenAI limit of 1000 enum values";
150
+ if (state.stringLength > 12e4) return "$: schema exceeds OpenAI limit of 120000 schema string characters";
151
+ }
152
+ function visitSchema(schema, path, objectDepth, state) {
153
+ if (!isRecord$2(schema)) return `${path}: schema must be an object`;
154
+ for (const keyword of Object.keys(schema)) if (!supportedKeywords.has(keyword)) return `${path}.${keyword}: keyword is not supported by OpenAI strict schemas`;
155
+ if (schema.$ref !== void 0 && typeof schema.$ref !== "string") return `${path}.$ref: must be a string`;
156
+ const typeIssue = validateType(schema.type, path);
157
+ if (typeIssue) return typeIssue;
158
+ const types = typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? schema.type : [];
159
+ const isObject = types.includes("object");
160
+ const isArray = types.includes("array");
161
+ const isString = types.includes("string");
162
+ const isNumeric = types.includes("number") || types.includes("integer");
163
+ const nextObjectDepth = isObject ? objectDepth + 1 : objectDepth;
164
+ if (nextObjectDepth > 10) return `${path}: schema exceeds OpenAI limit of 10 object nesting levels`;
165
+ if (!isObject && hasAny(schema, [
166
+ "properties",
167
+ "required",
168
+ "additionalProperties"
169
+ ])) return `${path}: object keywords require type "object"`;
170
+ if (!isArray && hasAny(schema, [
171
+ "items",
172
+ "minItems",
173
+ "maxItems"
174
+ ])) return `${path}: array keywords require type "array"`;
175
+ if (!isString && hasAny(schema, ["pattern", "format"])) return `${path}: string keywords require type "string"`;
176
+ if (!isNumeric && hasAny(schema, [
177
+ "minimum",
178
+ "maximum",
179
+ "exclusiveMinimum",
180
+ "exclusiveMaximum",
181
+ "multipleOf"
182
+ ])) return `${path}: numeric keywords require type "number" or "integer"`;
183
+ if (isObject) {
184
+ if (!isRecord$2(schema.properties)) return `${path}.properties: must be an object`;
185
+ if (schema.additionalProperties !== false) return `${path}.additionalProperties: must be false`;
186
+ const propertyNames = Object.keys(schema.properties);
187
+ const required = schema.required;
188
+ if (!Array.isArray(required) || required.some((name) => typeof name !== "string") || required.length !== propertyNames.length || propertyNames.some((name) => !required.includes(name))) return `${path}.required: must contain every property exactly once`;
189
+ state.propertyCount += propertyNames.length;
190
+ state.stringLength += propertyNames.reduce((total, name) => total + name.length, 0);
191
+ for (const name of propertyNames) {
192
+ const issue = visitSchema(schema.properties[name], `${path}.properties.${name}`, nextObjectDepth, state);
193
+ if (issue) return issue;
194
+ }
195
+ }
196
+ if (isArray) {
197
+ if (!isRecord$2(schema.items)) return `${path}.items: must be a schema object`;
198
+ const issue = visitSchema(schema.items, `${path}.items`, objectDepth, state);
199
+ if (issue) return issue;
200
+ }
201
+ if (schema.anyOf !== void 0) {
202
+ if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) return `${path}.anyOf: must be a non-empty array`;
203
+ for (let index = 0; index < schema.anyOf.length; index += 1) {
204
+ const issue = visitSchema(schema.anyOf[index], `${path}.anyOf[${index}]`, objectDepth, state);
205
+ if (issue) return issue;
206
+ }
207
+ }
208
+ const definitions = schema.$defs;
209
+ if (definitions !== void 0) {
210
+ if (!isRecord$2(definitions)) return `${path}.$defs: must be an object`;
211
+ const names = Object.keys(definitions);
212
+ state.stringLength += names.reduce((total, name) => total + name.length, 0);
213
+ for (const name of names) {
214
+ const issue = visitSchema(definitions[name], `${path}.$defs.${name}`, objectDepth, state);
215
+ if (issue) return issue;
216
+ }
217
+ }
218
+ if (schema.enum !== void 0) {
219
+ if (!Array.isArray(schema.enum) || schema.enum.some((value) => !isJsonPrimitive(value))) return `${path}.enum: must contain only JSON primitive values`;
220
+ state.enumCount += schema.enum.length;
221
+ const enumStringLength = schema.enum.reduce((total, value) => total + (typeof value === "string" ? value.length : 0), 0);
222
+ state.stringLength += enumStringLength;
223
+ if (schema.enum.length > 250 && enumStringLength > 15e3) return `${path}.enum: exceeds OpenAI limit of 15000 characters for large enums`;
224
+ }
225
+ if (schema.const !== void 0 && !isJsonPrimitive(schema.const)) return `${path}.const: must be a JSON primitive`;
226
+ if (typeof schema.const === "string") state.stringLength += schema.const.length;
227
+ if (schema.pattern !== void 0 && typeof schema.pattern !== "string") return `${path}.pattern: must be a string`;
228
+ if (schema.format !== void 0 && (typeof schema.format !== "string" || !supportedFormats.has(schema.format))) return `${path}.format: format "${String(schema.format)}" is not supported by OpenAI`;
229
+ for (const keyword of [
230
+ "minimum",
231
+ "maximum",
232
+ "exclusiveMinimum",
233
+ "exclusiveMaximum",
234
+ "multipleOf"
235
+ ]) if (schema[keyword] !== void 0 && (typeof schema[keyword] !== "number" || !Number.isFinite(schema[keyword]))) return `${path}.${keyword}: must be a number`;
236
+ for (const keyword of ["minItems", "maxItems"]) if (schema[keyword] !== void 0 && (typeof schema[keyword] !== "number" || !Number.isInteger(schema[keyword]) || schema[keyword] < 0)) return `${path}.${keyword}: must be a non-negative integer`;
237
+ }
238
+ function validateType(value, path) {
239
+ if (value === void 0) return void 0;
240
+ if (typeof value === "string") return supportedTypes.has(value) ? void 0 : `${path}.type: type "${value}" is not supported by OpenAI`;
241
+ if (Array.isArray(value) && value.length === 2 && value.filter((type) => type === "null").length === 1 && value.every((type) => typeof type === "string" && supportedTypes.has(type))) return;
242
+ return `${path}.type: must be a supported type or a nullable two-type union`;
243
+ }
244
+ function isRecord$2(value) {
245
+ return typeof value === "object" && value !== null && !Array.isArray(value);
246
+ }
247
+ function hasAny(schema, keywords) {
248
+ return keywords.some((keyword) => schema[keyword] !== void 0);
249
+ }
250
+ function isJsonPrimitive(value) {
251
+ return value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value);
252
+ }
253
+ //#endregion
254
+ //#region src/internal/response.ts
255
+ function parseOpenAIResponse(response, input, modelId) {
256
+ assertCompletedResponse(response);
257
+ if (!Array.isArray(response.output)) throw new OpenAIError("OpenAI response returned an invalid output array");
258
+ const providerOutput = response.output;
259
+ const toolCalls = parseToolCalls(providerOutput);
260
+ const output = parseOutput(providerOutput, input.outputSchema !== void 0);
261
+ const usage = parseUsage(response.usage);
262
+ const providerState = toolCalls?.length ? appendOpenAIProviderState(readOpenAIProviderState(input.providerState, modelId), modelId, providerOutput, toolCalls.map(({ id }) => id)) : void 0;
263
+ if (output === void 0 && !toolCalls?.length) throw new OpenAIError("OpenAI response returned no output");
264
+ return {
265
+ ...output === void 0 ? {} : { output },
266
+ ...toolCalls?.length ? { toolCalls } : {},
267
+ ...usage ? { usage } : {},
268
+ ...providerState ? { providerState } : {}
269
+ };
270
+ }
271
+ function parseToolCalls(output) {
272
+ if (!Array.isArray(output)) return void 0;
273
+ const calls = [];
274
+ const callIds = /* @__PURE__ */ new Set();
275
+ for (const item of output) {
276
+ if (!isRecord$1(item) || item.type !== "function_call") continue;
277
+ if (typeof item.call_id !== "string" || !item.call_id.trim()) throw new OpenAIError("OpenAI tool call returned an invalid call_id");
278
+ if (typeof item.name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(item.name)) throw new OpenAIError("OpenAI tool call returned an invalid name");
279
+ if (callIds.has(item.call_id)) throw new OpenAIError(`OpenAI tool call id "${item.call_id}" is duplicated`);
280
+ callIds.add(item.call_id);
281
+ if (typeof item.arguments !== "string") throw new OpenAIError(`OpenAI tool call "${item.name}" returned invalid arguments`);
282
+ let parsedArguments;
283
+ try {
284
+ parsedArguments = JSON.parse(item.arguments);
285
+ } catch (error) {
286
+ throw new OpenAIError(`OpenAI tool call "${item.name}" returned invalid JSON arguments`, { cause: error });
287
+ }
288
+ if (!isRecord$1(parsedArguments)) throw new OpenAIError(`OpenAI tool call "${item.name}" arguments must be a JSON object`);
289
+ calls.push({
290
+ id: item.call_id,
291
+ name: item.name,
292
+ input: parsedArguments
293
+ });
294
+ }
295
+ return calls.length ? calls : void 0;
296
+ }
297
+ function parseOutput(providerOutput, structured) {
298
+ const texts = [];
299
+ for (const item of providerOutput) {
300
+ if (!isRecord$1(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
301
+ for (const content of item.content) {
302
+ if (!isRecord$1(content)) continue;
303
+ if (content.type === "refusal" && typeof content.refusal === "string") throw new OpenAIError(`OpenAI refused the response: ${content.refusal}`);
304
+ if (content.type === "output_text" && typeof content.text === "string") texts.push(content.text);
305
+ }
306
+ }
307
+ const text = texts.length ? texts.join("") : void 0;
308
+ if (text === void 0) return void 0;
309
+ if (!structured) return text;
310
+ try {
311
+ return JSON.parse(text);
312
+ } catch (error) {
313
+ throw new OpenAIError("OpenAI structured output was not valid JSON", { cause: error });
314
+ }
315
+ }
316
+ function assertCompletedResponse(response) {
317
+ if (response.status === "completed") return;
318
+ if (typeof response.status !== "string") throw new OpenAIError("OpenAI response returned an invalid status");
319
+ let detail;
320
+ if (isRecord$1(response.error) && typeof response.error.message === "string") detail = response.error.message;
321
+ else if (isRecord$1(response.incomplete_details) && typeof response.incomplete_details.reason === "string") detail = response.incomplete_details.reason;
322
+ throw new OpenAIError(`OpenAI response was ${response.status}${detail ? `: ${detail}` : ""}`);
323
+ }
324
+ function parseUsage(usage) {
325
+ if (!usage) return void 0;
326
+ return {
327
+ ...typeof usage.input_tokens === "number" ? { inputTokens: usage.input_tokens } : {},
328
+ ...typeof usage.output_tokens === "number" ? { outputTokens: usage.output_tokens } : {},
329
+ ...typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}
330
+ };
331
+ }
332
+ function isRecord$1(value) {
333
+ return typeof value === "object" && value !== null && !Array.isArray(value);
334
+ }
335
+ //#endregion
336
+ //#region src/internal/sse.ts
337
+ async function* readSSE(body, signal) {
338
+ if (!body) throw new TypeError("SSE response body is missing");
339
+ const reader = body.getReader();
340
+ const decoder = new TextDecoder();
341
+ let buffer = "";
342
+ let finished = false;
343
+ const takeFrame = () => {
344
+ const match = /\r?\n\r?\n/.exec(buffer);
345
+ if (!match || match.index === void 0) return void 0;
346
+ const frame = buffer.slice(0, match.index);
347
+ buffer = buffer.slice(match.index + match[0].length);
348
+ return frame;
349
+ };
350
+ const data = (frame) => {
351
+ const lines = frame.split(/\r?\n/).filter((line) => line === "data" || line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, ""));
352
+ return lines.length ? lines.join("\n") : void 0;
353
+ };
354
+ try {
355
+ while (true) {
356
+ signal?.throwIfAborted();
357
+ const next = await readChunk(reader, signal);
358
+ if (next.done) {
359
+ finished = true;
360
+ buffer += decoder.decode();
361
+ if (buffer) {
362
+ const value = data(buffer);
363
+ if (value !== void 0) yield value;
364
+ }
365
+ return;
366
+ }
367
+ buffer += decoder.decode(next.value, { stream: true });
368
+ let frame;
369
+ while ((frame = takeFrame()) !== void 0) {
370
+ const value = data(frame);
371
+ if (value !== void 0) yield value;
372
+ }
373
+ }
374
+ } finally {
375
+ if (!finished) await reader.cancel().catch(() => {});
376
+ reader.releaseLock();
377
+ }
378
+ }
379
+ function readChunk(reader, signal) {
380
+ if (!signal) return reader.read();
381
+ signal.throwIfAborted();
382
+ return new Promise((resolve, reject) => {
383
+ const aborted = () => reject(signal.reason);
384
+ signal.addEventListener("abort", aborted, { once: true });
385
+ reader.read().then((value) => {
386
+ signal.removeEventListener("abort", aborted);
387
+ resolve(value);
388
+ }, (error) => {
389
+ signal.removeEventListener("abort", aborted);
390
+ reject(error);
391
+ });
392
+ });
393
+ }
394
+ //#endregion
395
+ //#region src/internal/responses-api.ts
396
+ const defaultBaseURL = "https://api.openai.com/v1";
397
+ async function* streamOpenAIResponse(config, modelId, input) {
398
+ input.signal?.throwIfAborted();
399
+ assertCompatibleInput(input);
400
+ if (config.schemaPolicy === "strict") assertStrictSchemas(input);
401
+ const body = buildRequestBody(config, modelId, input);
402
+ let response;
403
+ try {
404
+ response = await config.fetch(toResponsesURL(config.baseURL), {
405
+ method: "POST",
406
+ headers: buildHeaders(config),
407
+ body: JSON.stringify(body),
408
+ signal: input.signal
409
+ });
410
+ } catch (error) {
411
+ if (input.signal?.aborted) throw input.signal.reason;
412
+ throw new OpenAIError("OpenAI request failed", { cause: error });
413
+ }
414
+ input.signal?.throwIfAborted();
415
+ const requestId = response.headers.get("x-request-id") ?? void 0;
416
+ if (!response.ok) {
417
+ const data = await readResponseJson(response, requestId, input.signal);
418
+ throw new OpenAIError(toOpenAIErrorMessage(data, response.status), {
419
+ status: response.status,
420
+ requestId,
421
+ cause: data
422
+ });
423
+ }
424
+ let completed = false;
425
+ try {
426
+ for await (const data of readSSE(response.body, input.signal)) {
427
+ if (data === "[DONE]") continue;
428
+ let event;
429
+ try {
430
+ event = JSON.parse(data);
431
+ } catch (error) {
432
+ throw new OpenAIError("OpenAI stream event was not valid JSON", {
433
+ status: response.status,
434
+ requestId,
435
+ cause: error
436
+ });
437
+ }
438
+ if (!isRecord(event) || typeof event.type !== "string") throw new OpenAIError("OpenAI stream event was invalid", {
439
+ status: response.status,
440
+ requestId,
441
+ cause: event
442
+ });
443
+ if (event.type === "response.output_text.delta") {
444
+ if (typeof event.delta !== "string") throw new OpenAIError("OpenAI output delta was invalid", {
445
+ status: response.status,
446
+ requestId,
447
+ cause: event
448
+ });
449
+ if (event.delta) yield {
450
+ type: "output.delta",
451
+ delta: event.delta
452
+ };
453
+ continue;
454
+ }
455
+ if (event.type === "response.completed") {
456
+ if (completed || !isRecord(event.response)) throw new OpenAIError("OpenAI stream returned an invalid completion", {
457
+ status: response.status,
458
+ requestId,
459
+ cause: event
460
+ });
461
+ completed = true;
462
+ yield {
463
+ type: "completed",
464
+ result: parseOpenAIResponse(event.response, input, modelId)
465
+ };
466
+ continue;
467
+ }
468
+ if (event.type === "response.failed" || event.type === "response.incomplete") {
469
+ if (!isRecord(event.response)) throw new OpenAIError("OpenAI stream returned an invalid terminal response", {
470
+ status: response.status,
471
+ requestId,
472
+ cause: event
473
+ });
474
+ parseOpenAIResponse(event.response, input, modelId);
475
+ }
476
+ if (event.type === "error") throw new OpenAIError(toOpenAIStreamErrorMessage(event), {
477
+ status: response.status,
478
+ requestId,
479
+ cause: event
480
+ });
481
+ }
482
+ } catch (error) {
483
+ if (input.signal?.aborted) throw input.signal.reason;
484
+ if (error instanceof OpenAIError) throw error;
485
+ throw new OpenAIError("OpenAI stream failed", {
486
+ status: response.status,
487
+ requestId,
488
+ cause: error
489
+ });
490
+ }
491
+ if (!completed) throw new OpenAIError("OpenAI stream ended without response.completed", {
492
+ status: response.status,
493
+ requestId
494
+ });
495
+ }
496
+ function buildHeaders(config) {
497
+ return {
498
+ authorization: `Bearer ${config.apiKey}`,
499
+ "content-type": "application/json",
500
+ accept: "text/event-stream",
501
+ ...config.organization ? { "openai-organization": config.organization } : {},
502
+ ...config.project ? { "openai-project": config.project } : {}
503
+ };
504
+ }
505
+ function buildRequestBody(config, modelId, input) {
506
+ const strict = config.schemaPolicy === "strict";
507
+ const options = { ...input.modelOptions ?? {} };
508
+ const providerLimit = options.max_output_tokens;
509
+ const providerReasoning = options.reasoning;
510
+ const providerText = options.text;
511
+ for (const key of [
512
+ "model",
513
+ "input",
514
+ "tools",
515
+ "tool_choice",
516
+ "parallel_tool_calls",
517
+ "max_output_tokens",
518
+ "text",
519
+ "reasoning",
520
+ "stream",
521
+ "background",
522
+ "previous_response_id",
523
+ "conversation"
524
+ ]) delete options[key];
525
+ const messages = input.outputSchema && !strict ? withSchemaInstruction(input.messages, input.outputSchema) : input.messages;
526
+ const maxOutputTokens = cappedTokenLimit(input.maxOutputTokens, providerLimit);
527
+ const text = toOpenAIText(config, input, providerText);
528
+ const reasoning = toOpenAIReasoning(input, providerReasoning);
529
+ return {
530
+ ...options,
531
+ model: modelId,
532
+ stream: true,
533
+ input: toOpenAIInput(messages, readOpenAIProviderState(input.providerState, modelId)),
534
+ ...input.tools?.length ? {
535
+ tools: input.tools.map((tool) => toOpenAITool(tool, strict)),
536
+ parallel_tool_calls: false
537
+ } : {},
538
+ ...text === void 0 ? {} : { text },
539
+ ...reasoning === void 0 ? {} : { reasoning },
540
+ ...maxOutputTokens === void 0 ? {} : { max_output_tokens: maxOutputTokens }
541
+ };
542
+ }
543
+ function toResponsesURL(baseURL) {
544
+ return `${(baseURL ?? defaultBaseURL).replace(/\/$/, "")}/responses`;
545
+ }
546
+ function cappedTokenLimit(runtimeLimit, providerLimit) {
547
+ if (runtimeLimit === void 0) return providerLimit;
548
+ return typeof providerLimit === "number" && Number.isFinite(providerLimit) && providerLimit > 0 ? Math.min(runtimeLimit, providerLimit) : runtimeLimit;
549
+ }
550
+ async function readResponseJson(response, requestId, signal) {
551
+ const text = await response.text();
552
+ signal?.throwIfAborted();
553
+ if (!text) return {};
554
+ try {
555
+ return JSON.parse(text);
556
+ } catch (error) {
557
+ throw new OpenAIError("OpenAI response was not valid JSON", {
558
+ status: response.status,
559
+ requestId,
560
+ cause: error
561
+ });
562
+ }
563
+ }
564
+ function toOpenAIInput(messages, state) {
565
+ const input = [];
566
+ let turnIndex = 0;
567
+ for (const message of messages) {
568
+ if (message.role === "tool") {
569
+ input.push({
570
+ type: "function_call_output",
571
+ call_id: message.toolCallId ?? "",
572
+ output: message.content
573
+ });
574
+ continue;
575
+ }
576
+ if (message.role === "assistant" && message.toolCalls?.length) {
577
+ const turn = state?.turns[turnIndex];
578
+ if (!turn) throw new OpenAIError("OpenAI providerState is required for assistant tool-call history");
579
+ const ids = message.toolCalls.map(({ id }) => id);
580
+ if (!sameStrings(turn.toolCallIds, ids)) throw new OpenAIError("OpenAI providerState does not match assistant tool-call history");
581
+ input.push(...turn.output);
582
+ turnIndex += 1;
583
+ continue;
584
+ }
585
+ input.push({
586
+ role: message.role,
587
+ content: message.content
588
+ });
589
+ }
590
+ if (turnIndex !== (state?.turns.length ?? 0)) throw new OpenAIError("OpenAI providerState does not match assistant tool-call history");
591
+ return input;
592
+ }
593
+ function toOpenAIText(config, input, providerText) {
594
+ if (!input.outputSchema) return providerText;
595
+ const text = isRecord(providerText) ? providerText : {};
596
+ if (config.schemaPolicy === "strict") return {
597
+ ...text,
598
+ format: {
599
+ type: "json_schema",
600
+ name: config.schemaName ?? "fevex_output",
601
+ strict: true,
602
+ schema: input.outputSchema
603
+ }
604
+ };
605
+ if (!isObjectRoot(input.outputSchema) && providerText === void 0) return;
606
+ return {
607
+ ...text,
608
+ format: isObjectRoot(input.outputSchema) ? { type: "json_object" } : { type: "text" }
609
+ };
610
+ }
611
+ function toOpenAIReasoning(input, providerReasoning) {
612
+ if (!input.reasoning || input.reasoning === "provider-default") return providerReasoning;
613
+ return {
614
+ ...isRecord(providerReasoning) ? providerReasoning : {},
615
+ effort: input.reasoning
616
+ };
617
+ }
618
+ function toOpenAITool(tool, strict) {
619
+ return {
620
+ type: "function",
621
+ name: tool.name,
622
+ ...tool.description ? { description: tool.description } : {},
623
+ parameters: tool.inputSchema ?? emptyObjectSchema(strict),
624
+ strict
625
+ };
626
+ }
627
+ function assertStrictSchemas(input) {
628
+ for (const tool of input.tools ?? []) assertStrictSchema(tool.inputSchema ?? emptyObjectSchema(true), `tool-input "${tool.name}"`);
629
+ if (input.outputSchema) assertStrictSchema(input.outputSchema, "output");
630
+ }
631
+ function assertCompatibleInput(input) {
632
+ if (!isRecord(input)) throw new OpenAIError("OpenAI input must be an object");
633
+ if (!Array.isArray(input.messages) || input.messages.length === 0) throw new OpenAIError("OpenAI messages must be a non-empty array");
634
+ if (input.modelOptions !== void 0 && !isRecord(input.modelOptions)) throw new OpenAIError("OpenAI modelOptions must be an object");
635
+ if (input.maxOutputTokens !== void 0 && (!Number.isInteger(input.maxOutputTokens) || input.maxOutputTokens < 1)) throw new OpenAIError("OpenAI maxOutputTokens must be a positive integer");
636
+ if (input.reasoning !== void 0 && ![
637
+ "provider-default",
638
+ "none",
639
+ "minimal",
640
+ "low",
641
+ "medium",
642
+ "high"
643
+ ].includes(input.reasoning)) throw new OpenAIError("OpenAI reasoning effort is invalid");
644
+ if (input.outputSchema !== void 0 && !isRecord(input.outputSchema)) throw new OpenAIError("OpenAI outputSchema must be an object");
645
+ if (input.tools !== void 0 && !Array.isArray(input.tools)) throw new OpenAIError("OpenAI tools must be an array");
646
+ if ((input.tools?.length ?? 0) > 128) throw new OpenAIError("OpenAI supports at most 128 tools");
647
+ const toolNames = /* @__PURE__ */ new Set();
648
+ for (const tool of input.tools ?? []) {
649
+ if (!isRecord(tool) || !isProviderName(tool.name)) throw new OpenAIError("OpenAI tool names must match [A-Za-z0-9_-]{1,64}");
650
+ if (toolNames.has(tool.name)) throw new OpenAIError(`OpenAI tool "${tool.name}" is duplicated`);
651
+ toolNames.add(tool.name);
652
+ }
653
+ const callIds = /* @__PURE__ */ new Set();
654
+ const completedCallIds = /* @__PURE__ */ new Set();
655
+ for (const message of input.messages) {
656
+ if (!isRecord(message) || ![
657
+ "system",
658
+ "user",
659
+ "assistant",
660
+ "tool"
661
+ ].includes(String(message.role)) || typeof message.content !== "string") throw new OpenAIError("OpenAI message is invalid");
662
+ if (message.role === "assistant" && message.toolCalls !== void 0) {
663
+ if (!Array.isArray(message.toolCalls)) throw new OpenAIError("OpenAI assistant toolCalls must be an array");
664
+ for (const call of message.toolCalls) {
665
+ if (!isRecord(call) || typeof call.id !== "string" || !call.id.trim() || !isProviderName(call.name)) throw new OpenAIError("OpenAI assistant tool call is invalid");
666
+ if (callIds.has(call.id)) throw new OpenAIError(`OpenAI tool call id "${call.id}" is duplicated`);
667
+ callIds.add(call.id);
668
+ }
669
+ }
670
+ if (message.role === "tool") {
671
+ if (typeof message.toolCallId !== "string" || !message.toolCallId.trim() || !callIds.has(message.toolCallId) || completedCallIds.has(message.toolCallId)) throw new OpenAIError("OpenAI tool result has an invalid toolCallId");
672
+ completedCallIds.add(message.toolCallId);
673
+ }
674
+ }
675
+ if (completedCallIds.size !== callIds.size) throw new OpenAIError("OpenAI assistant tool call is missing a tool result");
676
+ }
677
+ function assertStrictSchema(schema, boundary) {
678
+ const issue = findOpenAISchemaIssue(schema);
679
+ if (!issue) return;
680
+ throw new OpenAIError(`OpenAI strict schema for ${boundary} is unsupported at ${issue}`, { code: PROVIDER_SCHEMA_UNSUPPORTED });
681
+ }
682
+ function emptyObjectSchema(strict) {
683
+ return strict ? {
684
+ type: "object",
685
+ properties: {},
686
+ required: [],
687
+ additionalProperties: false
688
+ } : {
689
+ type: "object",
690
+ properties: {}
691
+ };
692
+ }
693
+ function withSchemaInstruction(messages, schema) {
694
+ return [{
695
+ role: "system",
696
+ content: `Respond with a ${isObjectRoot(schema) ? "JSON object" : "JSON value"} matching this schema: ${JSON.stringify(schema)}`
697
+ }, ...messages];
698
+ }
699
+ function isObjectRoot(schema) {
700
+ return schema.type === "object" && schema.anyOf === void 0;
701
+ }
702
+ function isProviderName(value) {
703
+ return typeof value === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(value);
704
+ }
705
+ function sameStrings(left, right) {
706
+ return left.length === right.length && left.every((value, index) => value === right[index]);
707
+ }
708
+ function toOpenAIErrorMessage(data, status) {
709
+ if (isRecord(data) && isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
710
+ return `OpenAI request failed with status ${status}`;
711
+ }
712
+ function toOpenAIStreamErrorMessage(event) {
713
+ if (typeof event.message === "string") return event.message;
714
+ if (isRecord(event.error) && typeof event.error.message === "string") return event.error.message;
715
+ return "OpenAI stream failed";
716
+ }
717
+ function isRecord(value) {
718
+ return typeof value === "object" && value !== null && !Array.isArray(value);
719
+ }
720
+ //#endregion
721
+ //#region src/index.ts
722
+ function createOpenAI(config) {
723
+ const resolvedConfig = resolveOpenAIConfig(config);
724
+ return (modelId) => {
725
+ if (typeof modelId !== "string" || !modelId.trim()) throw new TypeError("OpenAI modelId cannot be empty");
726
+ return {
727
+ metadata: {
728
+ provider: "openai",
729
+ model: modelId
730
+ },
731
+ stateCodec: {
732
+ serialize: (state) => serializeOpenAIProviderState(state, modelId),
733
+ restore: (state) => restoreOpenAIProviderState(state, modelId)
734
+ },
735
+ stream(input) {
736
+ return streamOpenAIResponse(resolvedConfig, modelId, input);
737
+ }
738
+ };
739
+ };
740
+ }
741
+ //#endregion
742
+ export { OpenAIError, createOpenAI };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@fevex/openai",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "OpenAI ModelGateway adapter for Fevex.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "main": "./dist/index.mjs",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./dist/index.d.mts",
10
+ "files": ["dist"],
11
+ "sideEffects": false,
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/hemia-labs/fevex.git",
15
+ "directory": "packages/openai"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/hemia-labs/fevex/issues"
19
+ },
20
+ "homepage": "https://github.com/hemia-labs/fevex#readme",
21
+ "keywords": ["ai", "agents", "openai", "typescript", "fevex"],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.mts",
28
+ "import": "./dist/index.mjs"
29
+ }
30
+ },
31
+ "scripts": {
32
+ "build": "tsdown src/index.ts --format esm --dts",
33
+ "prepack": "bun run build",
34
+ "test": "bun test src/index.test.ts",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {
41
+ "@fevex/core": "0.1.0-alpha.1"
42
+ },
43
+ "devDependencies": {
44
+ "typescript": "^5.7.0"
45
+ }
46
+ }