@purista/harness-openai 1.2.4 → 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 -14
  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
@@ -251,7 +253,6 @@ async function createResponse(client, req, stream) {
251
253
  model: req.model,
252
254
  input: toResponsesInput(req.messages),
253
255
  stream,
254
- ...(stream ? { stream_options: { include_usage: true } } : {}),
255
256
  tools: toResponsesTools(req.tools),
256
257
  temperature: req.call?.temperature ?? req.defaults?.temperature,
257
258
  max_output_tokens: req.call?.maxTokens ?? req.defaults?.maxTokens,
@@ -369,6 +370,13 @@ function toResponsesInput(messages) {
369
370
  });
370
371
  continue;
371
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
+ }
372
380
  if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
373
381
  if (typeof message.content === 'string' && message.content.length > 0) {
374
382
  input.push({
@@ -378,9 +386,11 @@ function toResponsesInput(messages) {
378
386
  });
379
387
  }
380
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`.
381
392
  input.push({
382
393
  type: 'function_call',
383
- id: call.id,
384
394
  call_id: call.id,
385
395
  name: call.name,
386
396
  arguments: JSON.stringify(call.arguments)
@@ -446,18 +456,35 @@ function toResponsesTools(tools) {
446
456
  }
447
457
  function mapResponsesTextResponse(response, req) {
448
458
  const toolCalls = extractResponsesToolCalls(response, req, 'text');
459
+ const providerItems = toResponsesProviderItems(response.output, toolCalls);
449
460
  return {
450
461
  content: extractResponsesText(response),
451
462
  ...(toolCalls ? { toolCalls } : {}),
463
+ ...(providerItems ? { providerItems } : {}),
452
464
  usage: toResponsesUsage(response.usage),
453
465
  finishReason: toResponsesFinishReason(response),
454
466
  raw: response
455
467
  };
456
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
+ }
457
483
  async function* streamResponsesText(client, req) {
458
484
  const stream = await createResponse(client, req, true);
459
485
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
460
486
  let finishReason = 'stop';
487
+ let completedOutput;
461
488
  const toolState = new Map();
462
489
  for await (const event of stream) {
463
490
  req.signal.throwIfAborted();
@@ -476,21 +503,25 @@ async function* streamResponsesText(client, req) {
476
503
  else if (event.type === 'response.completed') {
477
504
  usage = toResponsesUsage(event.response?.usage);
478
505
  finishReason = toResponsesFinishReason(event.response);
506
+ completedOutput = event.response?.output;
479
507
  }
480
508
  else if (event.type === 'response.failed' || event.type === 'response.incomplete') {
481
509
  finishReason = 'error';
482
510
  }
483
511
  }
484
- for (const call of finalizeResponsesStreamToolCalls(toolState, req, 'textStream')) {
512
+ const toolCalls = finalizeResponsesStreamToolCalls(toolState, req, 'textStream');
513
+ for (const call of toolCalls) {
485
514
  yield { kind: 'tool_call', call };
486
515
  }
487
- yield { kind: 'finish', usage, finishReason };
516
+ const providerItems = toResponsesProviderItems(completedOutput, toolCalls);
517
+ yield { kind: 'finish', usage, finishReason, ...(providerItems ? { providerItems } : {}) };
488
518
  }
489
519
  async function* streamResponsesObject(client, req) {
490
520
  const stream = await createResponse(client, req, true);
491
521
  let partial = '';
492
522
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
493
523
  let finishReason = 'stop';
524
+ let completedOutput;
494
525
  const toolState = new Map();
495
526
  for await (const event of stream) {
496
527
  req.signal.throwIfAborted();
@@ -510,16 +541,19 @@ async function* streamResponsesObject(client, req) {
510
541
  else if (event.type === 'response.completed') {
511
542
  usage = toResponsesUsage(event.response?.usage);
512
543
  finishReason = toResponsesFinishReason(event.response);
544
+ completedOutput = event.response?.output;
513
545
  }
514
546
  else if (event.type === 'response.failed' || event.type === 'response.incomplete') {
515
547
  finishReason = 'error';
516
548
  }
517
549
  }
518
- for (const call of finalizeResponsesStreamToolCalls(toolState, req, 'objectStream')) {
550
+ const toolCalls = finalizeResponsesStreamToolCalls(toolState, req, 'objectStream');
551
+ for (const call of toolCalls) {
519
552
  yield { kind: 'tool_call', call };
520
553
  }
521
554
  const object = parseJson(partial || '{}', req, 'objectStream');
522
- yield { kind: 'finish', object, usage, finishReason };
555
+ const providerItems = toResponsesProviderItems(completedOutput, toolCalls);
556
+ yield { kind: 'finish', object, usage, finishReason, ...(providerItems ? { providerItems } : {}) };
523
557
  }
524
558
  function extractResponsesText(response) {
525
559
  if (typeof response.output_text === 'string')
@@ -569,8 +603,11 @@ function accumulateResponsesToolCallDelta(state, event) {
569
603
  function accumulateResponsesToolCallDone(state, event) {
570
604
  const index = typeof event.output_index === 'number' ? event.output_index : 0;
571
605
  const existing = state.get(index) ?? { args: '' };
572
- existing.id ??= String(event.item_id);
573
- 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);
574
611
  if (typeof event.arguments === 'string')
575
612
  existing.args = event.arguments;
576
613
  state.set(index, existing);
@@ -578,12 +615,17 @@ function accumulateResponsesToolCallDone(state, event) {
578
615
  function finalizeResponsesStreamToolCalls(state, req, method) {
579
616
  return [...state.entries()]
580
617
  .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
- }));
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
+ });
587
629
  }
588
630
  function accumulateToolCallDeltas(state, deltas) {
589
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.4",
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",