@purista/harness-bedrock 1.2.6 → 1.5.0

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 (2) hide show
  1. package/dist/index.js +94 -50
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { BaseModelProvider, ModelError } from '@purista/harness';
2
+ import { BaseModelProvider, parseProviderJson, safePartialJson, toTokenUsage, withoutObjectTool } from '@purista/harness';
3
3
  import { BedrockRuntimeClient, ConverseCommand, ConverseStreamCommand } from '@aws-sdk/client-bedrock-runtime';
4
4
  /**
5
5
  * Creates an Amazon Bedrock-backed harness `ModelProvider`.
@@ -30,22 +30,26 @@ class BedrockModelProvider extends BaseModelProvider {
30
30
  }
31
31
  async doText(req) {
32
32
  req.signal.throwIfAborted();
33
- const response = await this.client.send(new ConverseCommand(toConverseInput(req, false)), { abortSignal: req.signal });
33
+ const { input, requestOptions } = toConverseRequest(req, false);
34
+ const response = await this.client.send(new ConverseCommand(input), { ...requestOptions, abortSignal: req.signal });
34
35
  const toolCalls = extractToolCalls(response, req, 'text');
35
36
  return {
36
37
  content: outputText(response),
37
38
  ...(toolCalls ? { toolCalls } : {}),
38
- usage: toUsage(response.usage?.inputTokens, response.usage?.outputTokens),
39
+ usage: toTokenUsage(response.usage?.inputTokens, response.usage?.outputTokens),
39
40
  finishReason: toFinishReason(response.stopReason),
41
+ outcome: toOutcome(response.stopReason),
40
42
  raw: response
41
43
  };
42
44
  }
43
45
  async *doTextStream(req) {
44
46
  req.signal.throwIfAborted();
45
- const response = await this.client.send(new ConverseStreamCommand(toConverseInput(req, false)), { abortSignal: req.signal });
47
+ const { input, requestOptions } = toConverseRequest(req, false);
48
+ const response = await this.client.send(new ConverseStreamCommand(input), { ...requestOptions, abortSignal: req.signal });
46
49
  const toolState = new Map();
47
50
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
48
51
  let finishReason = 'stop';
52
+ let providerFinishReason;
49
53
  for await (const event of response.stream ?? []) {
50
54
  req.signal.throwIfAborted();
51
55
  if (event.contentBlockDelta?.delta?.text) {
@@ -71,70 +75,114 @@ class BedrockModelProvider extends BaseModelProvider {
71
75
  }
72
76
  }
73
77
  if (event.metadata?.usage) {
74
- usage = toUsage(event.metadata.usage.inputTokens, event.metadata.usage.outputTokens);
78
+ usage = toTokenUsage(event.metadata.usage.inputTokens, event.metadata.usage.outputTokens);
75
79
  }
76
80
  if (event.messageStop?.stopReason) {
77
- finishReason = toFinishReason(event.messageStop.stopReason);
81
+ providerFinishReason = event.messageStop.stopReason;
82
+ finishReason = toFinishReason(providerFinishReason);
78
83
  }
79
84
  }
80
- yield { kind: 'finish', usage, finishReason };
85
+ yield { kind: 'finish', usage, finishReason, outcome: streamOutcome(finishReason, providerFinishReason) };
81
86
  }
82
87
  async doObject(req) {
83
88
  req.signal.throwIfAborted();
84
- const response = await this.client.send(new ConverseCommand(toConverseInput(req, true)), { abortSignal: req.signal });
89
+ const { input, requestOptions } = toConverseRequest(req, true);
90
+ const response = await this.client.send(new ConverseCommand(input), { ...requestOptions, abortSignal: req.signal });
85
91
  const toolUse = response.output?.message?.content?.find((block) => block.toolUse?.name === 'harness_response')?.toolUse;
86
92
  const toolCalls = withoutObjectTool(extractToolCalls(response, req, 'object'));
87
93
  const object = (toolUse?.input ?? parseJson(outputText(response) || '{}', req, 'object'));
88
94
  return {
89
95
  object,
90
96
  ...(toolCalls ? { toolCalls } : {}),
91
- usage: toUsage(response.usage?.inputTokens, response.usage?.outputTokens),
97
+ usage: toTokenUsage(response.usage?.inputTokens, response.usage?.outputTokens),
92
98
  finishReason: toFinishReason(response.stopReason),
99
+ outcome: toOutcome(response.stopReason),
93
100
  raw: response
94
101
  };
95
102
  }
96
103
  async *doObjectStream(req) {
97
104
  req.signal.throwIfAborted();
98
- const response = await this.client.send(new ConverseStreamCommand(toConverseInput(req, true)), { abortSignal: req.signal });
105
+ const { input, requestOptions } = toConverseRequest(req, true);
106
+ const response = await this.client.send(new ConverseStreamCommand(input), { ...requestOptions, abortSignal: req.signal });
99
107
  let text = '';
100
108
  let objectInput = '';
109
+ let objectBlockIndex;
110
+ const toolState = new Map();
101
111
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
102
112
  let finishReason = 'stop';
113
+ let providerFinishReason;
103
114
  for await (const event of response.stream ?? []) {
104
115
  req.signal.throwIfAborted();
105
116
  if (event.contentBlockDelta?.delta?.text) {
106
117
  text += event.contentBlockDelta.delta.text;
107
118
  yield { kind: 'partial', partial: safePartialJson(text) };
108
119
  }
120
+ if (event.contentBlockStart?.start?.toolUse) {
121
+ // Only the synthetic `harness_response` block carries the structured
122
+ // object; other tool blocks are real tool calls and must not bleed
123
+ // into the object JSON (parity with the OpenAI/Azure adapters).
124
+ const blockIndex = event.contentBlockStart.contentBlockIndex ?? 0;
125
+ if (event.contentBlockStart.start.toolUse.name === 'harness_response' && objectBlockIndex === undefined) {
126
+ objectBlockIndex = blockIndex;
127
+ }
128
+ else {
129
+ toolState.set(blockIndex, {
130
+ id: String(event.contentBlockStart.start.toolUse.toolUseId),
131
+ name: String(event.contentBlockStart.start.toolUse.name),
132
+ input: ''
133
+ });
134
+ }
135
+ }
109
136
  if (event.contentBlockDelta?.delta?.toolUse?.input) {
110
- objectInput += event.contentBlockDelta.delta.toolUse.input;
111
- yield { kind: 'partial', partial: safePartialJson(objectInput) };
137
+ const blockIndex = event.contentBlockDelta.contentBlockIndex ?? 0;
138
+ const state = toolState.get(blockIndex);
139
+ if (state) {
140
+ state.input += event.contentBlockDelta.delta.toolUse.input;
141
+ }
142
+ else {
143
+ objectInput += event.contentBlockDelta.delta.toolUse.input;
144
+ yield { kind: 'partial', partial: safePartialJson(objectInput) };
145
+ }
146
+ }
147
+ if (event.contentBlockStop) {
148
+ const state = toolState.get(event.contentBlockStop.contentBlockIndex ?? 0);
149
+ if (state) {
150
+ yield { kind: 'tool_call', call: { id: state.id, name: state.name, arguments: parseJson(state.input || '{}', req, 'objectStream') } };
151
+ toolState.delete(event.contentBlockStop.contentBlockIndex ?? 0);
152
+ }
112
153
  }
113
154
  if (event.metadata?.usage) {
114
- usage = toUsage(event.metadata.usage.inputTokens, event.metadata.usage.outputTokens);
155
+ usage = toTokenUsage(event.metadata.usage.inputTokens, event.metadata.usage.outputTokens);
115
156
  }
116
157
  if (event.messageStop?.stopReason) {
117
- finishReason = toFinishReason(event.messageStop.stopReason);
158
+ providerFinishReason = event.messageStop.stopReason;
159
+ finishReason = toFinishReason(providerFinishReason);
118
160
  }
119
161
  }
120
162
  const object = parseJson(objectInput || text || '{}', req, 'objectStream');
121
- yield { kind: 'finish', object, usage, finishReason };
163
+ yield { kind: 'finish', object, usage, finishReason, outcome: streamOutcome(finishReason, providerFinishReason) };
122
164
  }
123
165
  }
124
166
  function toClientOptions(options) {
125
167
  const { client: _client, harnessLogger: _harnessLogger, telemetry: _telemetry, harnessTimeoutMs: _harnessTimeoutMs, ...clientOptions } = options;
126
- return clientOptions;
168
+ return { maxAttempts: 1, ...clientOptions };
127
169
  }
128
- function toConverseInput(req, forceObject) {
170
+ /**
171
+ * Builds the Converse request body and extracts `requestOptions`, which are
172
+ * per-request SDK transport options (mirroring the other adapters) and must
173
+ * not leak into the Converse request body.
174
+ */
175
+ function toConverseRequest(req, forceObject) {
129
176
  const providerOptions = {
130
177
  ...(req.defaults?.providerOptions ?? {}),
131
178
  ...(req.call?.providerOptions ?? {})
132
179
  };
180
+ const { requestOptions, ...bodyOptions } = providerOptions;
133
181
  const { system, messages } = toBedrockMessages(req.messages);
134
182
  const modelTools = toTools(req.tools) ?? [];
135
183
  const tools = forceObject ? [...modelTools, toObjectTool(req)] : modelTools;
136
184
  const forceObjectTool = forceObject && modelTools.length === 0;
137
- return {
185
+ const input = {
138
186
  modelId: req.model,
139
187
  messages,
140
188
  ...(system.length > 0 ? { system } : {}),
@@ -145,8 +193,9 @@ function toConverseInput(req, forceObject) {
145
193
  ...((req.call?.topP ?? req.defaults?.topP) !== undefined ? { topP: req.call?.topP ?? req.defaults?.topP } : {}),
146
194
  ...((req.call?.stopSequences ?? req.defaults?.stopSequences) !== undefined ? { stopSequences: req.call?.stopSequences ?? req.defaults?.stopSequences } : {})
147
195
  },
148
- ...providerOptions
196
+ ...bodyOptions
149
197
  };
198
+ return { input, ...(requestOptions ? { requestOptions } : {}) };
150
199
  }
151
200
  function toBedrockMessages(messages) {
152
201
  const system = messages.filter((message) => message.role === 'system').map((message) => ({ text: message.content }));
@@ -215,39 +264,12 @@ function extractToolCalls(response, req, method) {
215
264
  arguments: typeof call.input === 'string' ? parseJson(call.input, req, method) : call.input ?? {}
216
265
  }));
217
266
  }
218
- function withoutObjectTool(calls) {
219
- const filtered = calls?.filter((call) => call.name !== 'harness_response');
220
- return filtered && filtered.length > 0 ? filtered : undefined;
267
+ const MALFORMED_JSON_MESSAGE = 'Amazon Bedrock returned malformed structured JSON.';
268
+ function callContext(req, method) {
269
+ return { provider: 'bedrock', model: req.model, method };
221
270
  }
222
271
  function parseJson(content, req, method) {
223
- try {
224
- return JSON.parse(content);
225
- }
226
- catch (error) {
227
- throw malformedResponseError(req, method, 'Amazon Bedrock returned malformed structured JSON.', content, error);
228
- }
229
- }
230
- function malformedResponseError(req, method, message, body, cause) {
231
- return new ModelError(message, {
232
- provider: 'bedrock',
233
- model: req.model,
234
- method,
235
- reason: 'malformed_response',
236
- providerBody: body
237
- }, cause);
238
- }
239
- function safePartialJson(content) {
240
- try {
241
- return JSON.parse(content);
242
- }
243
- catch {
244
- return { _partial: content };
245
- }
246
- }
247
- function toUsage(inputTokens, outputTokens) {
248
- const input = inputTokens ?? 0;
249
- const output = outputTokens ?? 0;
250
- return { inputTokens: input, outputTokens: output, totalTokens: input + output };
272
+ return parseProviderJson(content, callContext(req, method), MALFORMED_JSON_MESSAGE);
251
273
  }
252
274
  function toFinishReason(value) {
253
275
  switch (value) {
@@ -261,7 +283,29 @@ function toFinishReason(value) {
261
283
  case 'content_filtered':
262
284
  case 'guardrail_intervened':
263
285
  return 'content_filter';
286
+ case 'malformed_model_output':
287
+ case 'malformed_tool_use':
288
+ return 'malformed';
289
+ case 'model_context_window_exceeded':
290
+ return 'context_limit';
264
291
  default:
265
292
  return 'error';
266
293
  }
267
294
  }
295
+ function toOutcome(value) {
296
+ const finishReason = toFinishReason(value);
297
+ return {
298
+ finishReason,
299
+ ...(typeof value === 'string' ? { providerFinishReason: value } : {})
300
+ };
301
+ }
302
+ /**
303
+ * Stream outcome built from the tracked finish reason; `providerFinishReason`
304
+ * is omitted entirely when the provider never sent a stop reason.
305
+ */
306
+ function streamOutcome(finishReason, providerFinishReason) {
307
+ return {
308
+ finishReason,
309
+ ...(typeof providerFinishReason === 'string' ? { providerFinishReason } : {})
310
+ };
311
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@purista/harness-bedrock",
3
- "version": "1.2.6",
3
+ "version": "1.5.0",
4
4
  "description": "Amazon Bedrock model provider adapter for @purista/harness.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  "vitest": "^4.1.8"
49
49
  },
50
50
  "peerDependencies": {
51
- "@purista/harness": "*"
51
+ "@purista/harness": "^1.5.0"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.15.0"