@purista/harness-openai 1.2.5 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +56 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -43,6 +43,14 @@ the adapter drops `reasoning_effort` and emits a warning instead of sending a
43
43
  request that OpenAI rejects. Use `api: 'responses'` when you need reasoning
44
44
  effort and tool calls together.
45
45
 
46
+ On the Responses API, tool-call responses carry the turn's raw output items
47
+ (including reasoning items) as `providerItems`. The harness agent loop passes
48
+ them back on the follow-up round and the adapter echoes them verbatim, as
49
+ OpenAI recommends for reasoning models with manually managed conversation
50
+ state. For stateless requests (`store: false`), additionally set
51
+ `providerOptions: { store: false, include: ['reasoning.encrypted_content'] }`
52
+ so the encrypted reasoning content rides along in the replayed items.
53
+
46
54
  ## Package Format
47
55
 
48
56
  This package is ESM-only and ships compiled JavaScript plus TypeScript
package/dist/index.js CHANGED
@@ -107,9 +107,11 @@ class OpenAiModelProvider extends BaseModelProvider {
107
107
  const response = await createResponse(this.client, req, false);
108
108
  const content = extractResponsesText(response);
109
109
  const toolCalls = extractResponsesToolCalls(response, req, 'object');
110
+ const providerItems = toResponsesProviderItems(response.output, toolCalls);
110
111
  return {
111
112
  object: parseJson(content || '{}', req, 'object'),
112
113
  ...(toolCalls ? { toolCalls } : {}),
114
+ ...(providerItems ? { providerItems } : {}),
113
115
  usage: toResponsesUsage(response.usage),
114
116
  finishReason: toResponsesFinishReason(response),
115
117
  raw: response
@@ -368,6 +370,13 @@ function toResponsesInput(messages) {
368
370
  });
369
371
  continue;
370
372
  }
373
+ if (message.role === 'assistant' && message.providerItems?.providerId === 'openai' && message.providerItems.items.length > 0) {
374
+ // Echo the captured turn (reasoning, message, and function_call items)
375
+ // verbatim, as the Responses API expects for manually managed state.
376
+ // Foreign provider items fall through to provider-neutral reconstruction.
377
+ input.push(...message.providerItems.items);
378
+ continue;
379
+ }
371
380
  if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
372
381
  if (typeof message.content === 'string' && message.content.length > 0) {
373
382
  input.push({
@@ -377,9 +386,11 @@ function toResponsesInput(messages) {
377
386
  });
378
387
  }
379
388
  for (const call of message.toolCalls) {
389
+ // The Responses API only accepts an `fc_…` item id on `function_call`
390
+ // input items; the harness tool-call id is the `call_…` value, so the
391
+ // optional item id is omitted, mirroring `function_call_output`.
380
392
  input.push({
381
393
  type: 'function_call',
382
- id: call.id,
383
394
  call_id: call.id,
384
395
  name: call.name,
385
396
  arguments: JSON.stringify(call.arguments)
@@ -445,18 +456,35 @@ function toResponsesTools(tools) {
445
456
  }
446
457
  function mapResponsesTextResponse(response, req) {
447
458
  const toolCalls = extractResponsesToolCalls(response, req, 'text');
459
+ const providerItems = toResponsesProviderItems(response.output, toolCalls);
448
460
  return {
449
461
  content: extractResponsesText(response),
450
462
  ...(toolCalls ? { toolCalls } : {}),
463
+ ...(providerItems ? { providerItems } : {}),
451
464
  usage: toResponsesUsage(response.usage),
452
465
  finishReason: toResponsesFinishReason(response),
453
466
  raw: response
454
467
  };
455
468
  }
469
+ /**
470
+ * Captures the turn's raw Responses output items on tool-call responses so
471
+ * they can be replayed verbatim on the follow-up round. OpenAI requires
472
+ * reasoning items returned with tool calls to be passed back with the tool
473
+ * outputs for reasoning models; echoing `response.output` unchanged is the
474
+ * pattern documented in the Responses migration guide.
475
+ */
476
+ function toResponsesProviderItems(output, toolCalls) {
477
+ if (!toolCalls || toolCalls.length === 0)
478
+ return undefined;
479
+ if (!Array.isArray(output) || output.length === 0)
480
+ return undefined;
481
+ return { providerId: 'openai', items: output };
482
+ }
456
483
  async function* streamResponsesText(client, req) {
457
484
  const stream = await createResponse(client, req, true);
458
485
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
459
486
  let finishReason = 'stop';
487
+ let completedOutput;
460
488
  const toolState = new Map();
461
489
  for await (const event of stream) {
462
490
  req.signal.throwIfAborted();
@@ -475,21 +503,25 @@ async function* streamResponsesText(client, req) {
475
503
  else if (event.type === 'response.completed') {
476
504
  usage = toResponsesUsage(event.response?.usage);
477
505
  finishReason = toResponsesFinishReason(event.response);
506
+ completedOutput = event.response?.output;
478
507
  }
479
508
  else if (event.type === 'response.failed' || event.type === 'response.incomplete') {
480
509
  finishReason = 'error';
481
510
  }
482
511
  }
483
- for (const call of finalizeResponsesStreamToolCalls(toolState, req, 'textStream')) {
512
+ const toolCalls = finalizeResponsesStreamToolCalls(toolState, req, 'textStream');
513
+ for (const call of toolCalls) {
484
514
  yield { kind: 'tool_call', call };
485
515
  }
486
- yield { kind: 'finish', usage, finishReason };
516
+ const providerItems = toResponsesProviderItems(completedOutput, toolCalls);
517
+ yield { kind: 'finish', usage, finishReason, ...(providerItems ? { providerItems } : {}) };
487
518
  }
488
519
  async function* streamResponsesObject(client, req) {
489
520
  const stream = await createResponse(client, req, true);
490
521
  let partial = '';
491
522
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
492
523
  let finishReason = 'stop';
524
+ let completedOutput;
493
525
  const toolState = new Map();
494
526
  for await (const event of stream) {
495
527
  req.signal.throwIfAborted();
@@ -509,16 +541,19 @@ async function* streamResponsesObject(client, req) {
509
541
  else if (event.type === 'response.completed') {
510
542
  usage = toResponsesUsage(event.response?.usage);
511
543
  finishReason = toResponsesFinishReason(event.response);
544
+ completedOutput = event.response?.output;
512
545
  }
513
546
  else if (event.type === 'response.failed' || event.type === 'response.incomplete') {
514
547
  finishReason = 'error';
515
548
  }
516
549
  }
517
- for (const call of finalizeResponsesStreamToolCalls(toolState, req, 'objectStream')) {
550
+ const toolCalls = finalizeResponsesStreamToolCalls(toolState, req, 'objectStream');
551
+ for (const call of toolCalls) {
518
552
  yield { kind: 'tool_call', call };
519
553
  }
520
554
  const object = parseJson(partial || '{}', req, 'objectStream');
521
- yield { kind: 'finish', object, usage, finishReason };
555
+ const providerItems = toResponsesProviderItems(completedOutput, toolCalls);
556
+ yield { kind: 'finish', object, usage, finishReason, ...(providerItems ? { providerItems } : {}) };
522
557
  }
523
558
  function extractResponsesText(response) {
524
559
  if (typeof response.output_text === 'string')
@@ -568,8 +603,11 @@ function accumulateResponsesToolCallDelta(state, event) {
568
603
  function accumulateResponsesToolCallDone(state, event) {
569
604
  const index = typeof event.output_index === 'number' ? event.output_index : 0;
570
605
  const existing = state.get(index) ?? { args: '' };
571
- existing.id ??= String(event.item_id);
572
- existing.name = String(event.name);
606
+ // `event.item_id` is the `fc_…` item id, not the `call_…` id required for
607
+ // `function_call_output`, so the call id only ever comes from the
608
+ // `response.output_item.added`/`done` events.
609
+ if (event.name)
610
+ existing.name = String(event.name);
573
611
  if (typeof event.arguments === 'string')
574
612
  existing.args = event.arguments;
575
613
  state.set(index, existing);
@@ -577,12 +615,17 @@ function accumulateResponsesToolCallDone(state, event) {
577
615
  function finalizeResponsesStreamToolCalls(state, req, method) {
578
616
  return [...state.entries()]
579
617
  .sort((a, b) => a[0] - b[0])
580
- .filter(([, call]) => call.id && call.name)
581
- .map(([, call]) => ({
582
- id: call.id,
583
- name: call.name,
584
- arguments: parseToolArgs(call.args || undefined, req, method)
585
- }));
618
+ .filter(([, call]) => call.name)
619
+ .map(([, call]) => {
620
+ if (!call.id) {
621
+ throw malformedResponseError(req, method, 'OpenAI streamed a function call without a call_id.', call, undefined);
622
+ }
623
+ return {
624
+ id: call.id,
625
+ name: call.name,
626
+ arguments: parseToolArgs(call.args || undefined, req, method)
627
+ };
628
+ });
586
629
  }
587
630
  function accumulateToolCallDeltas(state, deltas) {
588
631
  for (const delta of deltas) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@purista/harness-openai",
3
- "version": "1.2.5",
3
+ "version": "1.2.6",
4
4
  "description": "OpenAI model provider adapter for @purista/harness.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",