@purista/harness-openai 1.2.6 → 1.5.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/dist/index.d.ts CHANGED
@@ -53,6 +53,7 @@ export interface OpenAiFactoryOptions extends ClientOptions {
53
53
  * summarize: {
54
54
  * input: z.string(),
55
55
  * output: z.string(),
56
+ * delegation: { agents: ['assistant'] },
56
57
  * handler: (ctx) => ctx.agents.assistant(ctx.input)
57
58
  * }
58
59
  * })
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseModelProvider, ModelError } from '@purista/harness';
1
+ import { BaseModelProvider, ModelError, accumulateStreamToolCallDeltas, createStreamToolCallState, finalizeStreamToolCalls, malformedResponseError, parseProviderJson, safePartialJson, sanitizeProviderMessage, toTokenUsage } from '@purista/harness';
2
2
  import OpenAI, {} from 'openai';
3
3
  /**
4
4
  * Creates an OpenAI-backed harness `ModelProvider`.
@@ -32,6 +32,7 @@ import OpenAI, {} from 'openai';
32
32
  * summarize: {
33
33
  * input: z.string(),
34
34
  * output: z.string(),
35
+ * delegation: { agents: ['assistant'] },
35
36
  * handler: (ctx) => ctx.agents.assistant(ctx.input)
36
37
  * }
37
38
  * })
@@ -62,6 +63,7 @@ class OpenAiModelProvider extends BaseModelProvider {
62
63
  req.signal.throwIfAborted();
63
64
  if (this.options.api === 'responses') {
64
65
  const response = await createResponse(this.client, req, false);
66
+ throwIfResponsesFailure(response, req, 'text');
65
67
  return mapResponsesTextResponse(response, req);
66
68
  }
67
69
  const response = await createChatCompletion(this.client, req, false, this.getLogger());
@@ -76,12 +78,13 @@ class OpenAiModelProvider extends BaseModelProvider {
76
78
  const stream = await createChatCompletion(this.client, req, true, this.getLogger());
77
79
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
78
80
  let finishReason = 'stop';
79
- const toolState = new Map();
81
+ let providerFinishReason;
82
+ const toolState = createStreamToolCallState();
80
83
  for await (const chunk of stream) {
81
84
  req.signal.throwIfAborted();
82
85
  // The usage chunk arrives with an empty choices array, so read it first.
83
86
  if (chunk.usage) {
84
- usage = toUsage(chunk.usage.prompt_tokens, chunk.usage.completion_tokens);
87
+ usage = toTokenUsage(chunk.usage.prompt_tokens, chunk.usage.completion_tokens);
85
88
  }
86
89
  const choice = chunk.choices?.[0];
87
90
  if (!choice)
@@ -90,21 +93,23 @@ class OpenAiModelProvider extends BaseModelProvider {
90
93
  yield { kind: 'delta', text: choice.delta.content };
91
94
  }
92
95
  if (choice.delta?.tool_calls) {
93
- accumulateToolCallDeltas(toolState, choice.delta.tool_calls);
96
+ accumulateStreamToolCallDeltas(toolState, choice.delta.tool_calls);
94
97
  }
95
98
  if (choice.finish_reason) {
96
- finishReason = toFinishReason(choice.finish_reason);
99
+ providerFinishReason = choice.finish_reason;
100
+ finishReason = toFinishReason(providerFinishReason);
97
101
  }
98
102
  }
99
- for (const call of finalizeStreamToolCalls(toolState, req, 'textStream')) {
103
+ for (const call of finalizeStreamToolCalls(toolState, callContext(req, 'textStream'), MALFORMED_TOOL_ARGS_MESSAGE)) {
100
104
  yield { kind: 'tool_call', call };
101
105
  }
102
- yield { kind: 'finish', usage, finishReason };
106
+ yield { kind: 'finish', usage, finishReason, outcome: toOutcome(finishReason, providerFinishReason) };
103
107
  }
104
108
  async doObject(req) {
105
109
  req.signal.throwIfAborted();
106
110
  if (this.options.api === 'responses') {
107
111
  const response = await createResponse(this.client, req, false);
112
+ throwIfResponsesFailure(response, req, 'object');
108
113
  const content = extractResponsesText(response);
109
114
  const toolCalls = extractResponsesToolCalls(response, req, 'object');
110
115
  const providerItems = toResponsesProviderItems(response.output, toolCalls);
@@ -114,6 +119,7 @@ class OpenAiModelProvider extends BaseModelProvider {
114
119
  ...(providerItems ? { providerItems } : {}),
115
120
  usage: toResponsesUsage(response.usage),
116
121
  finishReason: toResponsesFinishReason(response),
122
+ outcome: toResponsesOutcome(response),
117
123
  raw: response
118
124
  };
119
125
  }
@@ -123,8 +129,9 @@ class OpenAiModelProvider extends BaseModelProvider {
123
129
  return {
124
130
  object: parseJson(textContent, req, 'object'),
125
131
  ...(toolCalls ? { toolCalls } : {}),
126
- usage: toUsage(response.usage?.prompt_tokens, response.usage?.completion_tokens),
132
+ usage: toTokenUsage(response.usage?.prompt_tokens, response.usage?.completion_tokens),
127
133
  finishReason: toFinishReason(response.choices[0]?.finish_reason),
134
+ outcome: toOutcome(toFinishReason(response.choices[0]?.finish_reason), response.choices[0]?.finish_reason),
128
135
  raw: response
129
136
  };
130
137
  }
@@ -133,7 +140,8 @@ class OpenAiModelProvider extends BaseModelProvider {
133
140
  let partial = '';
134
141
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
135
142
  let finishReason = 'stop';
136
- const toolState = new Map();
143
+ let providerFinishReason;
144
+ const toolState = createStreamToolCallState();
137
145
  if (this.options.api === 'responses') {
138
146
  yield* streamResponsesObject(this.client, req);
139
147
  return;
@@ -142,7 +150,7 @@ class OpenAiModelProvider extends BaseModelProvider {
142
150
  for await (const chunk of stream) {
143
151
  req.signal.throwIfAborted();
144
152
  if (chunk.usage) {
145
- usage = toUsage(chunk.usage.prompt_tokens, chunk.usage.completion_tokens);
153
+ usage = toTokenUsage(chunk.usage.prompt_tokens, chunk.usage.completion_tokens);
146
154
  }
147
155
  const choice = chunk.choices?.[0];
148
156
  if (!choice)
@@ -152,17 +160,18 @@ class OpenAiModelProvider extends BaseModelProvider {
152
160
  yield { kind: 'partial', partial: safePartialJson(partial) };
153
161
  }
154
162
  if (choice.delta?.tool_calls) {
155
- accumulateToolCallDeltas(toolState, choice.delta.tool_calls);
163
+ accumulateStreamToolCallDeltas(toolState, choice.delta.tool_calls);
156
164
  }
157
165
  if (choice.finish_reason) {
158
- finishReason = toFinishReason(choice.finish_reason);
166
+ providerFinishReason = choice.finish_reason;
167
+ finishReason = toFinishReason(providerFinishReason);
159
168
  }
160
169
  }
161
- for (const call of finalizeStreamToolCalls(toolState, req, 'objectStream')) {
170
+ for (const call of finalizeStreamToolCalls(toolState, callContext(req, 'objectStream'), MALFORMED_TOOL_ARGS_MESSAGE)) {
162
171
  yield { kind: 'tool_call', call };
163
172
  }
164
173
  const object = parseJson(partial || '{}', req, 'objectStream');
165
- yield { kind: 'finish', object, usage, finishReason };
174
+ yield { kind: 'finish', object, usage, finishReason, outcome: toOutcome(finishReason, providerFinishReason) };
166
175
  }
167
176
  async doEmbed(req) {
168
177
  req.signal.throwIfAborted();
@@ -178,22 +187,25 @@ class OpenAiModelProvider extends BaseModelProvider {
178
187
  }, { ...requestOptions, signal: req.signal });
179
188
  return {
180
189
  embeddings: response.data.map((item) => ({ index: item.index, vector: item.embedding })),
181
- usage: toUsage(response.usage?.prompt_tokens, 0),
190
+ usage: toTokenUsage(response.usage?.prompt_tokens, 0),
182
191
  raw: response
183
192
  };
184
193
  }
185
194
  }
186
195
  function toClientOptions(options) {
187
196
  const { api: _api, client: _client, harnessLogger: _harnessLogger, telemetry: _telemetry, harnessTimeoutMs: _harnessTimeoutMs, ...clientOptions } = options;
188
- return clientOptions;
197
+ return { maxRetries: 0, ...clientOptions };
189
198
  }
190
199
  function mapChatTextResponse(response, req) {
191
200
  const toolCalls = extractChatToolCalls(response, req, 'text');
201
+ const providerFinishReason = response.choices[0]?.finish_reason;
202
+ const finishReason = toFinishReason(providerFinishReason);
192
203
  return {
193
204
  content: response.choices[0]?.message?.content ?? '',
194
205
  ...(toolCalls ? { toolCalls } : {}),
195
- usage: toUsage(response.usage?.prompt_tokens, response.usage?.completion_tokens),
196
- finishReason: toFinishReason(response.choices[0]?.finish_reason),
206
+ usage: toTokenUsage(response.usage?.prompt_tokens, response.usage?.completion_tokens),
207
+ finishReason,
208
+ outcome: toOutcome(finishReason, providerFinishReason),
197
209
  raw: response
198
210
  };
199
211
  }
@@ -463,6 +475,7 @@ function mapResponsesTextResponse(response, req) {
463
475
  ...(providerItems ? { providerItems } : {}),
464
476
  usage: toResponsesUsage(response.usage),
465
477
  finishReason: toResponsesFinishReason(response),
478
+ outcome: toResponsesOutcome(response),
466
479
  raw: response
467
480
  };
468
481
  }
@@ -484,6 +497,7 @@ async function* streamResponsesText(client, req) {
484
497
  const stream = await createResponse(client, req, true);
485
498
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
486
499
  let finishReason = 'stop';
500
+ let outcome = toOutcome('stop');
487
501
  let completedOutput;
488
502
  const toolState = new Map();
489
503
  for await (const event of stream) {
@@ -503,10 +517,18 @@ async function* streamResponsesText(client, req) {
503
517
  else if (event.type === 'response.completed') {
504
518
  usage = toResponsesUsage(event.response?.usage);
505
519
  finishReason = toResponsesFinishReason(event.response);
520
+ outcome = toResponsesOutcome(event.response);
506
521
  completedOutput = event.response?.output;
507
522
  }
508
- else if (event.type === 'response.failed' || event.type === 'response.incomplete') {
509
- finishReason = 'error';
523
+ else if (event.type === 'response.failed') {
524
+ // A genuine provider failure must surface as an error so base retry and
525
+ // normalization apply, matching the chat-completions path.
526
+ throw responsesFailureError(event.response, req, 'textStream');
527
+ }
528
+ else if (event.type === 'response.incomplete') {
529
+ usage = toResponsesUsage(event.response?.usage);
530
+ finishReason = toResponsesFinishReason(event.response);
531
+ outcome = toResponsesOutcome(event.response);
510
532
  }
511
533
  }
512
534
  const toolCalls = finalizeResponsesStreamToolCalls(toolState, req, 'textStream');
@@ -514,13 +536,14 @@ async function* streamResponsesText(client, req) {
514
536
  yield { kind: 'tool_call', call };
515
537
  }
516
538
  const providerItems = toResponsesProviderItems(completedOutput, toolCalls);
517
- yield { kind: 'finish', usage, finishReason, ...(providerItems ? { providerItems } : {}) };
539
+ yield { kind: 'finish', usage, finishReason, outcome, ...(providerItems ? { providerItems } : {}) };
518
540
  }
519
541
  async function* streamResponsesObject(client, req) {
520
542
  const stream = await createResponse(client, req, true);
521
543
  let partial = '';
522
544
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
523
545
  let finishReason = 'stop';
546
+ let outcome = toOutcome('stop');
524
547
  let completedOutput;
525
548
  const toolState = new Map();
526
549
  for await (const event of stream) {
@@ -541,10 +564,18 @@ async function* streamResponsesObject(client, req) {
541
564
  else if (event.type === 'response.completed') {
542
565
  usage = toResponsesUsage(event.response?.usage);
543
566
  finishReason = toResponsesFinishReason(event.response);
567
+ outcome = toResponsesOutcome(event.response);
544
568
  completedOutput = event.response?.output;
545
569
  }
546
- else if (event.type === 'response.failed' || event.type === 'response.incomplete') {
547
- finishReason = 'error';
570
+ else if (event.type === 'response.failed') {
571
+ // A genuine provider failure must surface as an error so base retry and
572
+ // normalization apply, matching the chat-completions path.
573
+ throw responsesFailureError(event.response, req, 'objectStream');
574
+ }
575
+ else if (event.type === 'response.incomplete') {
576
+ usage = toResponsesUsage(event.response?.usage);
577
+ finishReason = toResponsesFinishReason(event.response);
578
+ outcome = toResponsesOutcome(event.response);
548
579
  }
549
580
  }
550
581
  const toolCalls = finalizeResponsesStreamToolCalls(toolState, req, 'objectStream');
@@ -553,7 +584,7 @@ async function* streamResponsesObject(client, req) {
553
584
  }
554
585
  const object = parseJson(partial || '{}', req, 'objectStream');
555
586
  const providerItems = toResponsesProviderItems(completedOutput, toolCalls);
556
- yield { kind: 'finish', object, usage, finishReason, ...(providerItems ? { providerItems } : {}) };
587
+ yield { kind: 'finish', object, usage, finishReason, outcome, ...(providerItems ? { providerItems } : {}) };
557
588
  }
558
589
  function extractResponsesText(response) {
559
590
  if (typeof response.output_text === 'string')
@@ -618,7 +649,7 @@ function finalizeResponsesStreamToolCalls(state, req, method) {
618
649
  .filter(([, call]) => call.name)
619
650
  .map(([, call]) => {
620
651
  if (!call.id) {
621
- throw malformedResponseError(req, method, 'OpenAI streamed a function call without a call_id.', call, undefined);
652
+ throw malformedResponseError(callContext(req, method), 'OpenAI streamed a function call without a call_id.', call, undefined);
622
653
  }
623
654
  return {
624
655
  id: call.id,
@@ -627,75 +658,46 @@ function finalizeResponsesStreamToolCalls(state, req, method) {
627
658
  };
628
659
  });
629
660
  }
630
- function accumulateToolCallDeltas(state, deltas) {
631
- for (const delta of deltas) {
632
- const index = typeof delta?.index === 'number' ? delta.index : 0;
633
- const existing = state.get(index) ?? { args: '' };
634
- if (delta?.id)
635
- existing.id = String(delta.id);
636
- if (delta?.function?.name)
637
- existing.name = String(delta.function.name);
638
- if (typeof delta?.function?.arguments === 'string')
639
- existing.args += delta.function.arguments;
640
- state.set(index, existing);
641
- }
661
+ const MALFORMED_TOOL_ARGS_MESSAGE = 'OpenAI returned malformed tool-call argument JSON.';
662
+ const MALFORMED_OBJECT_MESSAGE = 'OpenAI returned malformed structured object JSON.';
663
+ function callContext(req, method) {
664
+ return { provider: 'openai', model: req.model, method };
642
665
  }
643
- function finalizeStreamToolCalls(state, req, method) {
644
- return [...state.entries()]
645
- .sort((a, b) => a[0] - b[0])
646
- .filter(([, call]) => call.id && call.name)
647
- .map(([, call]) => ({
648
- id: call.id,
649
- name: call.name,
650
- arguments: parseToolArgs(call.args || undefined, req, method)
651
- }));
652
- }
653
- function parseToolArgs(argumentsText, req, method) {
654
- if (!argumentsText)
655
- return {};
656
- try {
657
- return JSON.parse(argumentsText);
658
- }
659
- catch (error) {
660
- throw malformedResponseError(req, method, 'OpenAI returned malformed tool-call argument JSON.', argumentsText, error);
661
- }
662
- }
663
- function parseJson(content, req, method) {
664
- try {
665
- return JSON.parse(content);
666
- }
667
- catch (error) {
668
- throw malformedResponseError(req, method, 'OpenAI returned malformed structured object JSON.', content, error);
666
+ /** Throws when a non-streaming Responses result reports a provider failure. */
667
+ function throwIfResponsesFailure(response, req, method) {
668
+ if (response?.status === 'failed' || response?.error) {
669
+ throw responsesFailureError(response, req, method);
669
670
  }
670
671
  }
671
- function malformedResponseError(req, method, message, body, cause) {
672
- return new ModelError(message, {
672
+ /**
673
+ * Maps a failed Responses-API result into a `ModelError` so the base
674
+ * provider's retry classification and normalization apply.
675
+ */
676
+ function responsesFailureError(response, req, method) {
677
+ const providerCode = typeof response?.error?.code === 'string' ? response.error.code : undefined;
678
+ const rawMessage = typeof response?.error?.message === 'string' ? response.error.message : undefined;
679
+ const reason = providerCode === 'rate_limit_exceeded'
680
+ ? 'rate_limited'
681
+ : providerCode === 'server_error'
682
+ ? 'provider_unavailable'
683
+ : 'http_error';
684
+ return new ModelError('OpenAI reported a failed response.', {
673
685
  provider: 'openai',
674
686
  model: req.model,
675
687
  method,
676
- reason: 'malformed_response',
677
- providerBody: body
678
- }, cause);
688
+ reason,
689
+ ...(providerCode ? { providerCode } : {}),
690
+ ...(rawMessage ? { providerMessage: sanitizeProviderMessage(rawMessage) } : {})
691
+ });
679
692
  }
680
- function safePartialJson(content) {
681
- try {
682
- return JSON.parse(content);
683
- }
684
- catch {
685
- return { _partial: content };
686
- }
693
+ function parseToolArgs(argumentsText, req, method) {
694
+ return parseProviderJson(argumentsText || '{}', callContext(req, method), MALFORMED_TOOL_ARGS_MESSAGE);
687
695
  }
688
- function toUsage(inputTokens, outputTokens) {
689
- const input = inputTokens ?? 0;
690
- const output = outputTokens ?? 0;
691
- return {
692
- inputTokens: input,
693
- outputTokens: output,
694
- totalTokens: input + output
695
- };
696
+ function parseJson(content, req, method) {
697
+ return parseProviderJson(content, callContext(req, method), MALFORMED_OBJECT_MESSAGE);
696
698
  }
697
699
  function toResponsesUsage(usage) {
698
- return toUsage(usage?.input_tokens, usage?.output_tokens);
700
+ return toTokenUsage(usage?.input_tokens, usage?.output_tokens);
699
701
  }
700
702
  function toFinishReason(value) {
701
703
  switch (value) {
@@ -704,6 +706,8 @@ function toFinishReason(value) {
704
706
  case 'tool_calls':
705
707
  case 'content_filter':
706
708
  return value;
709
+ case 'function_call':
710
+ return 'tool_calls';
707
711
  default:
708
712
  return 'error';
709
713
  }
@@ -713,6 +717,11 @@ function toResponsesFinishReason(response) {
713
717
  return 'error';
714
718
  if ((response.output ?? []).some((item) => item?.type === 'function_call'))
715
719
  return 'tool_calls';
720
+ const incompleteReason = response.incomplete_details?.reason;
721
+ if (incompleteReason === 'max_output_tokens')
722
+ return 'length';
723
+ if (incompleteReason === 'content_filter')
724
+ return 'content_filter';
716
725
  switch (response.status) {
717
726
  case 'completed':
718
727
  return 'stop';
@@ -722,3 +731,24 @@ function toResponsesFinishReason(response) {
722
731
  return response.error ? 'error' : 'stop';
723
732
  }
724
733
  }
734
+ function toOutcome(finishReason, providerFinishReason, details) {
735
+ return {
736
+ finishReason,
737
+ ...(typeof providerFinishReason === 'string' ? { providerFinishReason } : {}),
738
+ ...(details ? { details } : {})
739
+ };
740
+ }
741
+ function toResponsesOutcome(response) {
742
+ const finishReason = toResponsesFinishReason(response);
743
+ const details = response?.incomplete_details || response?.error
744
+ ? {
745
+ ...(response.incomplete_details ? { incompleteDetails: response.incomplete_details } : {}),
746
+ ...(response.error ? { error: response.error } : {})
747
+ }
748
+ : undefined;
749
+ return {
750
+ finishReason,
751
+ ...(typeof response?.status === 'string' ? { providerStatus: response.status, providerFinishReason: response.status } : {}),
752
+ ...(details ? { details } : {})
753
+ };
754
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@purista/harness-openai",
3
- "version": "1.2.6",
3
+ "version": "1.5.1",
4
4
  "description": "OpenAI model provider adapter for @purista/harness.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,15 +40,15 @@
40
40
  "lint": "npm run typecheck"
41
41
  },
42
42
  "dependencies": {
43
- "openai": "^6.42.0"
43
+ "openai": "^6.43.0"
44
44
  },
45
45
  "devDependencies": {
46
- "@vitest/coverage-v8": "^4.1.8",
46
+ "@vitest/coverage-v8": "^4.1.9",
47
47
  "typescript": "^6.0.3",
48
- "vitest": "^4.1.8"
48
+ "vitest": "^4.1.9"
49
49
  },
50
50
  "peerDependencies": {
51
- "@purista/harness": "*"
51
+ "@purista/harness": "^1.5.1"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.15.0"