@purista/harness-openai 1.2.3 → 1.2.4

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 CHANGED
@@ -12,6 +12,37 @@ Configure the provider with an OpenAI API key in your application environment.
12
12
  The adapter is designed for use through the typed `@purista/harness` model
13
13
  provider port.
14
14
 
15
+ ```ts
16
+ import { openai } from '@purista/harness-openai'
17
+
18
+ const provider = openai({
19
+ apiKey: process.env.OPENAI_API_KEY!,
20
+ baseURL: process.env.OPENAI_BASE_URL
21
+ })
22
+ ```
23
+
24
+ By default, generation uses Chat Completions for compatibility with
25
+ OpenAI-compatible endpoints:
26
+
27
+ ```ts
28
+ openai({ apiKey: process.env.OPENAI_API_KEY! })
29
+ ```
30
+
31
+ Use the Responses API for OpenAI reasoning models that require function tools
32
+ and reasoning effort on `/v1/responses`, such as `gpt-5.5`:
33
+
34
+ ```ts
35
+ openai({
36
+ apiKey: process.env.OPENAI_API_KEY!,
37
+ api: 'responses'
38
+ })
39
+ ```
40
+
41
+ When Chat Completions is used with tools and `providerOptions.reasoning_effort`,
42
+ the adapter drops `reasoning_effort` and emits a warning instead of sending a
43
+ request that OpenAI rejects. Use `api: 'responses'` when you need reasoning
44
+ effort and tool calls together.
45
+
15
46
  ## Package Format
16
47
 
17
48
  This package is ESM-only and ships compiled JavaScript plus TypeScript
package/dist/index.d.ts CHANGED
@@ -4,6 +4,14 @@ import { type ClientOptions } from 'openai';
4
4
  * Configuration for the OpenAI model provider factory.
5
5
  */
6
6
  export interface OpenAiFactoryOptions extends ClientOptions {
7
+ /**
8
+ * OpenAI API surface used for text/object generation.
9
+ *
10
+ * Use `responses` for reasoning models that require the Responses API for
11
+ * function tools with reasoning effort, such as `gpt-5.5`. The default keeps
12
+ * existing OpenAI-compatible chat-completions endpoints working.
13
+ */
14
+ api?: 'chat_completions' | 'responses';
7
15
  /** Optional injected client for tests or custom transport behavior. */
8
16
  client?: OpenAiClient;
9
17
  /** Optional adapter-level logger override. Defaults to the harness logger when registered. */
@@ -18,7 +26,7 @@ export interface OpenAiFactoryOptions extends ClientOptions {
18
26
  *
19
27
  * Execution model:
20
28
  * - In-process adapter code
21
- * - External network calls to OpenAI-compatible chat completions endpoint
29
+ * - External network calls to OpenAI chat completions or responses endpoint
22
30
  * - AsyncIterable streaming for `textStream` and `objectStream`
23
31
  *
24
32
  * @example
@@ -63,6 +71,11 @@ export type OpenAiClient = {
63
71
  }): Promise<any>;
64
72
  };
65
73
  };
74
+ responses?: {
75
+ create(payload: unknown, options?: {
76
+ signal?: AbortSignal;
77
+ }): Promise<any>;
78
+ };
66
79
  embeddings: {
67
80
  create(payload: unknown, options?: {
68
81
  signal?: AbortSignal;
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import OpenAI, {} from 'openai';
5
5
  *
6
6
  * Execution model:
7
7
  * - In-process adapter code
8
- * - External network calls to OpenAI-compatible chat completions endpoint
8
+ * - External network calls to OpenAI chat completions or responses endpoint
9
9
  * - AsyncIterable streaming for `textStream` and `objectStream`
10
10
  *
11
11
  * @example
@@ -60,12 +60,20 @@ class OpenAiModelProvider extends BaseModelProvider {
60
60
  }
61
61
  async doText(req) {
62
62
  req.signal.throwIfAborted();
63
- const response = await createChatCompletion(this.client, req, false);
64
- return mapTextResponse(response, req);
63
+ if (this.options.api === 'responses') {
64
+ const response = await createResponse(this.client, req, false);
65
+ return mapResponsesTextResponse(response, req);
66
+ }
67
+ const response = await createChatCompletion(this.client, req, false, this.getLogger());
68
+ return mapChatTextResponse(response, req);
65
69
  }
66
70
  async *doTextStream(req) {
67
71
  req.signal.throwIfAborted();
68
- const stream = await createChatCompletion(this.client, req, true);
72
+ if (this.options.api === 'responses') {
73
+ yield* streamResponsesText(this.client, req);
74
+ return;
75
+ }
76
+ const stream = await createChatCompletion(this.client, req, true, this.getLogger());
69
77
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
70
78
  let finishReason = 'stop';
71
79
  const toolState = new Map();
@@ -95,9 +103,21 @@ class OpenAiModelProvider extends BaseModelProvider {
95
103
  }
96
104
  async doObject(req) {
97
105
  req.signal.throwIfAborted();
98
- const response = await createChatCompletion(this.client, req, false);
106
+ if (this.options.api === 'responses') {
107
+ const response = await createResponse(this.client, req, false);
108
+ const content = extractResponsesText(response);
109
+ const toolCalls = extractResponsesToolCalls(response, req, 'object');
110
+ return {
111
+ object: parseJson(content || '{}', req, 'object'),
112
+ ...(toolCalls ? { toolCalls } : {}),
113
+ usage: toResponsesUsage(response.usage),
114
+ finishReason: toResponsesFinishReason(response),
115
+ raw: response
116
+ };
117
+ }
118
+ const response = await createChatCompletion(this.client, req, false, this.getLogger());
99
119
  const textContent = response.choices[0]?.message?.content ?? '{}';
100
- const toolCalls = extractToolCalls(response, req, 'object');
120
+ const toolCalls = extractChatToolCalls(response, req, 'object');
101
121
  return {
102
122
  object: parseJson(textContent, req, 'object'),
103
123
  ...(toolCalls ? { toolCalls } : {}),
@@ -112,7 +132,11 @@ class OpenAiModelProvider extends BaseModelProvider {
112
132
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
113
133
  let finishReason = 'stop';
114
134
  const toolState = new Map();
115
- const stream = await createChatCompletion(this.client, req, true);
135
+ if (this.options.api === 'responses') {
136
+ yield* streamResponsesObject(this.client, req);
137
+ return;
138
+ }
139
+ const stream = await createChatCompletion(this.client, req, true, this.getLogger());
116
140
  for await (const chunk of stream) {
117
141
  req.signal.throwIfAborted();
118
142
  if (chunk.usage) {
@@ -158,11 +182,11 @@ class OpenAiModelProvider extends BaseModelProvider {
158
182
  }
159
183
  }
160
184
  function toClientOptions(options) {
161
- const { client: _client, harnessLogger: _harnessLogger, telemetry: _telemetry, harnessTimeoutMs: _harnessTimeoutMs, ...clientOptions } = options;
185
+ const { api: _api, client: _client, harnessLogger: _harnessLogger, telemetry: _telemetry, harnessTimeoutMs: _harnessTimeoutMs, ...clientOptions } = options;
162
186
  return clientOptions;
163
187
  }
164
- function mapTextResponse(response, req) {
165
- const toolCalls = extractToolCalls(response, req, 'text');
188
+ function mapChatTextResponse(response, req) {
189
+ const toolCalls = extractChatToolCalls(response, req, 'text');
166
190
  return {
167
191
  content: response.choices[0]?.message?.content ?? '',
168
192
  ...(toolCalls ? { toolCalls } : {}),
@@ -171,7 +195,7 @@ function mapTextResponse(response, req) {
171
195
  raw: response
172
196
  };
173
197
  }
174
- function extractToolCalls(response, req, method) {
198
+ function extractChatToolCalls(response, req, method) {
175
199
  const toolCalls = response.choices[0]?.message?.tool_calls;
176
200
  if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
177
201
  return undefined;
@@ -184,13 +208,14 @@ function extractToolCalls(response, req, method) {
184
208
  arguments: parseToolArgs(call.function.arguments, req, method)
185
209
  }));
186
210
  }
187
- async function createChatCompletion(client, req, stream) {
211
+ async function createChatCompletion(client, req, stream, logger) {
188
212
  const messages = toOpenAiMessages(req.messages);
189
213
  const providerOptions = {
190
214
  ...(req.defaults?.providerOptions ?? {}),
191
215
  ...(req.call?.providerOptions ?? {})
192
216
  };
193
217
  const { requestOptions, ...bodyOptions } = providerOptions;
218
+ const normalizedBodyOptions = omitUnsupportedChatCompletionOptions(bodyOptions, req, logger);
194
219
  return client.chat.completions.create({
195
220
  model: req.model,
196
221
  messages,
@@ -204,9 +229,58 @@ async function createChatCompletion(client, req, stream) {
204
229
  stop: req.call?.stopSequences ?? req.defaults?.stopSequences,
205
230
  ...(req.tools && (req.call?.parallelToolCalls ?? req.defaults?.parallelToolCalls) !== undefined ? { parallel_tool_calls: req.call?.parallelToolCalls ?? req.defaults?.parallelToolCalls } : {}),
206
231
  response_format: toResponseFormat(req),
232
+ ...normalizedBodyOptions
233
+ }, { ...requestOptions, signal: req.signal });
234
+ }
235
+ async function createResponse(client, req, stream) {
236
+ if (!client.responses?.create) {
237
+ throw new ModelError('OpenAI client does not expose the Responses API.', {
238
+ provider: 'openai',
239
+ model: req.model,
240
+ method: stream ? ('schema' in req ? 'objectStream' : 'textStream') : ('schema' in req ? 'object' : 'text'),
241
+ reason: 'unstructured_response',
242
+ providerBody: { api: 'responses' }
243
+ });
244
+ }
245
+ const providerOptions = {
246
+ ...(req.defaults?.providerOptions ?? {}),
247
+ ...(req.call?.providerOptions ?? {})
248
+ };
249
+ const { requestOptions, ...bodyOptions } = toResponsesProviderOptions(providerOptions);
250
+ return client.responses.create({
251
+ model: req.model,
252
+ input: toResponsesInput(req.messages),
253
+ stream,
254
+ ...(stream ? { stream_options: { include_usage: true } } : {}),
255
+ tools: toResponsesTools(req.tools),
256
+ temperature: req.call?.temperature ?? req.defaults?.temperature,
257
+ max_output_tokens: req.call?.maxTokens ?? req.defaults?.maxTokens,
258
+ top_p: req.call?.topP ?? req.defaults?.topP,
259
+ parallel_tool_calls: req.tools && (req.call?.parallelToolCalls ?? req.defaults?.parallelToolCalls) !== undefined ? req.call?.parallelToolCalls ?? req.defaults?.parallelToolCalls : undefined,
260
+ text: toResponsesTextFormat(req),
207
261
  ...bodyOptions
208
262
  }, { ...requestOptions, signal: req.signal });
209
263
  }
264
+ function omitUnsupportedChatCompletionOptions(bodyOptions, req, logger) {
265
+ if (!req.tools || req.tools.length === 0 || !('reasoning_effort' in bodyOptions))
266
+ return bodyOptions;
267
+ const { reasoning_effort: _reasoningEffort, ...rest } = bodyOptions;
268
+ logger?.warn('OpenAI reasoning_effort dropped for chat completions with tools.', {
269
+ provider: 'openai',
270
+ model: req.model,
271
+ api: 'chat_completions',
272
+ reason: 'reasoning_effort_not_supported_with_tools',
273
+ recommendation: "Use openai({ api: 'responses' }) for reasoning models that require tools with reasoning effort."
274
+ });
275
+ return rest;
276
+ }
277
+ function toResponsesProviderOptions(providerOptions) {
278
+ const { reasoning_effort: reasoningEffort, reasoning, ...rest } = providerOptions;
279
+ if (reasoningEffort !== undefined && reasoning === undefined) {
280
+ return { ...rest, reasoning: { effort: reasoningEffort } };
281
+ }
282
+ return { ...rest, ...(reasoning !== undefined ? { reasoning } : {}) };
283
+ }
210
284
  function toResponseFormat(req) {
211
285
  if (!('schema' in req))
212
286
  return undefined;
@@ -219,6 +293,18 @@ function toResponseFormat(req) {
219
293
  }
220
294
  };
221
295
  }
296
+ function toResponsesTextFormat(req) {
297
+ if (!('schema' in req))
298
+ return undefined;
299
+ return {
300
+ format: {
301
+ type: 'json_schema',
302
+ name: 'harness_response',
303
+ strict: false,
304
+ schema: req.schema
305
+ }
306
+ };
307
+ }
222
308
  function toOpenAiMessages(messages) {
223
309
  return messages.map((message) => {
224
310
  if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
@@ -272,6 +358,67 @@ function toOpenAiMessages(messages) {
272
358
  };
273
359
  });
274
360
  }
361
+ function toResponsesInput(messages) {
362
+ const input = [];
363
+ for (const message of messages) {
364
+ if (message.role === 'tool') {
365
+ input.push({
366
+ type: 'function_call_output',
367
+ call_id: message.toolCallId,
368
+ output: message.content
369
+ });
370
+ continue;
371
+ }
372
+ if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
373
+ if (typeof message.content === 'string' && message.content.length > 0) {
374
+ input.push({
375
+ type: 'message',
376
+ role: 'assistant',
377
+ content: message.content
378
+ });
379
+ }
380
+ for (const call of message.toolCalls) {
381
+ input.push({
382
+ type: 'function_call',
383
+ id: call.id,
384
+ call_id: call.id,
385
+ name: call.name,
386
+ arguments: JSON.stringify(call.arguments)
387
+ });
388
+ }
389
+ continue;
390
+ }
391
+ input.push({
392
+ type: 'message',
393
+ role: message.role,
394
+ content: toResponsesMessageContent(message)
395
+ });
396
+ }
397
+ return input;
398
+ }
399
+ function toResponsesMessageContent(message) {
400
+ if (typeof message.content === 'string') {
401
+ return message.content;
402
+ }
403
+ return message.content.map((part) => {
404
+ if (part.kind === 'text') {
405
+ return { type: 'input_text', text: part.text };
406
+ }
407
+ if (part.kind === 'image') {
408
+ return {
409
+ type: 'input_image',
410
+ image_url: `data:${part.mimeType};base64,${part.dataBase64}`
411
+ };
412
+ }
413
+ if (part.kind === 'image_url') {
414
+ return {
415
+ type: 'input_image',
416
+ image_url: part.url
417
+ };
418
+ }
419
+ return { type: 'input_text', text: `[unsupported ${part.kind} content omitted]` };
420
+ });
421
+ }
275
422
  function toTools(tools) {
276
423
  if (!tools || tools.length === 0) {
277
424
  return undefined;
@@ -285,6 +432,159 @@ function toTools(tools) {
285
432
  }
286
433
  }));
287
434
  }
435
+ function toResponsesTools(tools) {
436
+ if (!tools || tools.length === 0) {
437
+ return undefined;
438
+ }
439
+ return tools.map((tool) => ({
440
+ type: 'function',
441
+ name: tool.name,
442
+ description: tool.description,
443
+ parameters: tool.parameters,
444
+ strict: false
445
+ }));
446
+ }
447
+ function mapResponsesTextResponse(response, req) {
448
+ const toolCalls = extractResponsesToolCalls(response, req, 'text');
449
+ return {
450
+ content: extractResponsesText(response),
451
+ ...(toolCalls ? { toolCalls } : {}),
452
+ usage: toResponsesUsage(response.usage),
453
+ finishReason: toResponsesFinishReason(response),
454
+ raw: response
455
+ };
456
+ }
457
+ async function* streamResponsesText(client, req) {
458
+ const stream = await createResponse(client, req, true);
459
+ let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
460
+ let finishReason = 'stop';
461
+ const toolState = new Map();
462
+ for await (const event of stream) {
463
+ req.signal.throwIfAborted();
464
+ if (event.type === 'response.output_text.delta') {
465
+ yield { kind: 'delta', text: event.delta };
466
+ }
467
+ else if (event.type === 'response.output_item.added' || event.type === 'response.output_item.done') {
468
+ accumulateResponsesToolCallItem(toolState, event);
469
+ }
470
+ else if (event.type === 'response.function_call_arguments.delta') {
471
+ accumulateResponsesToolCallDelta(toolState, event);
472
+ }
473
+ else if (event.type === 'response.function_call_arguments.done') {
474
+ accumulateResponsesToolCallDone(toolState, event);
475
+ }
476
+ else if (event.type === 'response.completed') {
477
+ usage = toResponsesUsage(event.response?.usage);
478
+ finishReason = toResponsesFinishReason(event.response);
479
+ }
480
+ else if (event.type === 'response.failed' || event.type === 'response.incomplete') {
481
+ finishReason = 'error';
482
+ }
483
+ }
484
+ for (const call of finalizeResponsesStreamToolCalls(toolState, req, 'textStream')) {
485
+ yield { kind: 'tool_call', call };
486
+ }
487
+ yield { kind: 'finish', usage, finishReason };
488
+ }
489
+ async function* streamResponsesObject(client, req) {
490
+ const stream = await createResponse(client, req, true);
491
+ let partial = '';
492
+ let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
493
+ let finishReason = 'stop';
494
+ const toolState = new Map();
495
+ for await (const event of stream) {
496
+ req.signal.throwIfAborted();
497
+ if (event.type === 'response.output_text.delta') {
498
+ partial += event.delta;
499
+ yield { kind: 'partial', partial: safePartialJson(partial) };
500
+ }
501
+ else if (event.type === 'response.output_item.added' || event.type === 'response.output_item.done') {
502
+ accumulateResponsesToolCallItem(toolState, event);
503
+ }
504
+ else if (event.type === 'response.function_call_arguments.delta') {
505
+ accumulateResponsesToolCallDelta(toolState, event);
506
+ }
507
+ else if (event.type === 'response.function_call_arguments.done') {
508
+ accumulateResponsesToolCallDone(toolState, event);
509
+ }
510
+ else if (event.type === 'response.completed') {
511
+ usage = toResponsesUsage(event.response?.usage);
512
+ finishReason = toResponsesFinishReason(event.response);
513
+ }
514
+ else if (event.type === 'response.failed' || event.type === 'response.incomplete') {
515
+ finishReason = 'error';
516
+ }
517
+ }
518
+ for (const call of finalizeResponsesStreamToolCalls(toolState, req, 'objectStream')) {
519
+ yield { kind: 'tool_call', call };
520
+ }
521
+ const object = parseJson(partial || '{}', req, 'objectStream');
522
+ yield { kind: 'finish', object, usage, finishReason };
523
+ }
524
+ function extractResponsesText(response) {
525
+ if (typeof response.output_text === 'string')
526
+ return response.output_text;
527
+ const parts = [];
528
+ for (const item of response.output ?? []) {
529
+ if (item?.type !== 'message')
530
+ continue;
531
+ for (const content of item.content ?? []) {
532
+ if (content?.type === 'output_text' && typeof content.text === 'string') {
533
+ parts.push(content.text);
534
+ }
535
+ }
536
+ }
537
+ return parts.join('');
538
+ }
539
+ function extractResponsesToolCalls(response, req, method) {
540
+ const toolCalls = (response.output ?? []).filter((item) => item?.type === 'function_call' && item.call_id && item.name);
541
+ if (toolCalls.length === 0)
542
+ return undefined;
543
+ return toolCalls.map((call) => ({
544
+ id: String(call.call_id),
545
+ name: String(call.name),
546
+ arguments: parseToolArgs(call.arguments, req, method)
547
+ }));
548
+ }
549
+ function accumulateResponsesToolCallItem(state, event) {
550
+ if (event.item?.type !== 'function_call')
551
+ return;
552
+ const index = typeof event.output_index === 'number' ? event.output_index : 0;
553
+ const existing = state.get(index) ?? { args: '' };
554
+ if (event.item.call_id)
555
+ existing.id = String(event.item.call_id);
556
+ if (event.item.name)
557
+ existing.name = String(event.item.name);
558
+ if (typeof event.item.arguments === 'string')
559
+ existing.args = event.item.arguments;
560
+ state.set(index, existing);
561
+ }
562
+ function accumulateResponsesToolCallDelta(state, event) {
563
+ const index = typeof event.output_index === 'number' ? event.output_index : 0;
564
+ const existing = state.get(index) ?? { args: '' };
565
+ if (typeof event.delta === 'string')
566
+ existing.args += event.delta;
567
+ state.set(index, existing);
568
+ }
569
+ function accumulateResponsesToolCallDone(state, event) {
570
+ const index = typeof event.output_index === 'number' ? event.output_index : 0;
571
+ const existing = state.get(index) ?? { args: '' };
572
+ existing.id ??= String(event.item_id);
573
+ existing.name = String(event.name);
574
+ if (typeof event.arguments === 'string')
575
+ existing.args = event.arguments;
576
+ state.set(index, existing);
577
+ }
578
+ function finalizeResponsesStreamToolCalls(state, req, method) {
579
+ return [...state.entries()]
580
+ .sort((a, b) => a[0] - b[0])
581
+ .filter(([, call]) => call.id && call.name)
582
+ .map(([, call]) => ({
583
+ id: call.id,
584
+ name: call.name,
585
+ arguments: parseToolArgs(call.args || undefined, req, method)
586
+ }));
587
+ }
288
588
  function accumulateToolCallDeltas(state, deltas) {
289
589
  for (const delta of deltas) {
290
590
  const index = typeof delta?.index === 'number' ? delta.index : 0;
@@ -352,6 +652,9 @@ function toUsage(inputTokens, outputTokens) {
352
652
  totalTokens: input + output
353
653
  };
354
654
  }
655
+ function toResponsesUsage(usage) {
656
+ return toUsage(usage?.input_tokens, usage?.output_tokens);
657
+ }
355
658
  function toFinishReason(value) {
356
659
  switch (value) {
357
660
  case 'stop':
@@ -363,3 +666,17 @@ function toFinishReason(value) {
363
666
  return 'error';
364
667
  }
365
668
  }
669
+ function toResponsesFinishReason(response) {
670
+ if (!response)
671
+ return 'error';
672
+ if ((response.output ?? []).some((item) => item?.type === 'function_call'))
673
+ return 'tool_calls';
674
+ switch (response.status) {
675
+ case 'completed':
676
+ return 'stop';
677
+ case 'incomplete':
678
+ return 'length';
679
+ default:
680
+ return response.error ? 'error' : 'stop';
681
+ }
682
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@purista/harness-openai",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "OpenAI model provider adapter for @purista/harness.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",