Skip to content

Commit 72eec65

Browse files
committed
fix: harden Bedrock invoke/stream request conversion and handlers
- Add optional logger to bedrockToCompletionRequest for warnings - Use deterministic tool_use_${index} fallback IDs for missing tool_use block IDs instead of random toolu_* values - Warn on unsupported content block types (image, document) that get dropped during conversion - Warn on unexpected message roles - Change textContent || null to ?? null to preserve empty strings - Set completionReq.stream = true in handleBedrockStream - Add webSearches warning on tool-call-only responses - Add JSDoc to bedrockUsage explaining zero-default behavior - Update test expectations for ?? semantics and deterministic IDs - Import shared test helpers from helpers/mock-res.ts
1 parent 419f708 commit 72eec65

2 files changed

Lines changed: 61 additions & 73 deletions

File tree

src/__tests__/bedrock.test.ts

Lines changed: 4 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createServer, type ServerInstance } from "../server.js";
55
import { bedrockToCompletionRequest, handleBedrock, handleBedrockStream } from "../bedrock.js";
66
import { Journal } from "../journal.js";
77
import { Logger } from "../logger.js";
8+
import { createMockReq, createMockRes, createDefaults } from "./helpers/mock-res.js";
89

910
// --- helpers ---
1011

@@ -635,7 +636,7 @@ describe("bedrockToCompletionRequest (edge cases)", () => {
635636
},
636637
"model",
637638
);
638-
expect(result.messages[0].tool_calls![0].id).toMatch(/^toolu_/);
639+
expect(result.messages[0].tool_calls![0].id).toBe("tool_use_0");
639640
});
640641

641642
it("handles assistant tool_use block with missing name (falls back to '')", () => {
@@ -750,8 +751,8 @@ describe("bedrockToCompletionRequest (edge cases)", () => {
750751
},
751752
"model",
752753
);
753-
// Empty array → no tool_use blocks, textContent is "" → null
754-
expect(result.messages[0]).toEqual({ role: "assistant", content: null });
754+
// Empty array → no tool_use blocks, textContent is "" → preserved as "" (not coerced to null via ??)
755+
expect(result.messages[0]).toEqual({ role: "assistant", content: "" });
755756
});
756757

757758
it("handles user message with content blocks but no tool_results (text extraction)", () => {
@@ -996,62 +997,6 @@ describe("POST /model/{modelId}/invoke (tool call with empty arguments)", () =>
996997
// Direct handler tests for req.method/req.url fallback branches
997998
// ---------------------------------------------------------------------------
998999

999-
function createMockReq(overrides: Partial<http.IncomingMessage> = {}): http.IncomingMessage {
1000-
return {
1001-
method: undefined,
1002-
url: undefined,
1003-
headers: {},
1004-
...overrides,
1005-
} as unknown as http.IncomingMessage;
1006-
}
1007-
1008-
function createMockRes(): http.ServerResponse & {
1009-
_written: string;
1010-
_status: number;
1011-
_headers: Record<string, string>;
1012-
} {
1013-
const res = {
1014-
_written: "",
1015-
_status: 0,
1016-
_headers: {} as Record<string, string>,
1017-
writableEnded: false,
1018-
statusCode: 0,
1019-
writeHead(status: number, headers?: Record<string, string>) {
1020-
res._status = status;
1021-
res.statusCode = status;
1022-
if (headers) Object.assign(res._headers, headers);
1023-
},
1024-
setHeader(name: string, value: string) {
1025-
res._headers[name] = value;
1026-
},
1027-
write(data: string) {
1028-
res._written += data;
1029-
return true;
1030-
},
1031-
end(data?: string) {
1032-
if (data) res._written += data;
1033-
res.writableEnded = true;
1034-
},
1035-
destroy() {
1036-
res.writableEnded = true;
1037-
},
1038-
};
1039-
return res as unknown as http.ServerResponse & {
1040-
_written: string;
1041-
_status: number;
1042-
_headers: Record<string, string>;
1043-
};
1044-
}
1045-
1046-
function createDefaults(overrides: Partial<HandlerDefaults> = {}): HandlerDefaults {
1047-
return {
1048-
latency: 0,
1049-
chunkSize: 100,
1050-
logger: new Logger("silent"),
1051-
...overrides,
1052-
};
1053-
}
1054-
10551000
describe("handleBedrock (direct handler call, method/url fallbacks)", () => {
10561001
it("uses fallback values when req.method and req.url are undefined", async () => {
10571002
const fixture: Fixture = {

src/bedrock.ts

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ function bedrockStopReason(
9595
return overrideFinishReason;
9696
}
9797

98+
/**
99+
* Build a Bedrock-style usage object from optional overrides.
100+
*
101+
* When no overrides are provided (the common case for mock fixtures),
102+
* returns all-zero token counts. This is intentional — aimock does not
103+
* attempt to estimate token usage from fixture content. Callers that
104+
* need realistic usage numbers should set `usage` in their fixture's
105+
* response overrides.
106+
*/
98107
function bedrockUsage(overrides?: ResponseOverrides): {
99108
input_tokens: number;
100109
output_tokens: number;
@@ -119,6 +128,7 @@ function extractTextContent(content: string | BedrockContentBlock[]): string {
119128
export function bedrockToCompletionRequest(
120129
req: BedrockRequest,
121130
modelId: string,
131+
logger?: Logger,
122132
): ChatCompletionRequest {
123133
const messages: ChatMessage[] = [];
124134

@@ -140,6 +150,17 @@ export function bedrockToCompletionRequest(
140150
if (msg.role === "user") {
141151
// Check for tool_result blocks
142152
if (typeof msg.content !== "string" && Array.isArray(msg.content)) {
153+
// Warn about non-text content blocks that will be dropped (image, document, etc.)
154+
const unsupportedBlocks = msg.content.filter(
155+
(b) => b.type !== "text" && b.type !== "tool_result",
156+
);
157+
if (unsupportedBlocks.length > 0 && logger) {
158+
const types = [...new Set(unsupportedBlocks.map((b) => b.type))].join(", ");
159+
logger.warn(
160+
`Bedrock user message contains unsupported content block types [${types}] — these will be dropped during conversion`,
161+
);
162+
}
163+
143164
const toolResults = msg.content.filter((b) => b.type === "tool_result");
144165
const textBlocks = msg.content.filter((b) => b.type === "text");
145166

@@ -183,22 +204,35 @@ export function bedrockToCompletionRequest(
183204
if (toolUseBlocks.length > 0) {
184205
messages.push({
185206
role: "assistant",
186-
content: textContent || null,
187-
tool_calls: toolUseBlocks.map((b) => ({
188-
id: b.id ?? generateToolUseId(),
189-
type: "function" as const,
190-
function: {
191-
name: b.name ?? "",
192-
arguments: typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? {}),
193-
},
194-
})),
207+
content: textContent ?? null,
208+
tool_calls: toolUseBlocks.map((b, index) => {
209+
if (!b.id && logger) {
210+
logger.warn(
211+
`Bedrock assistant tool_use block at index ${index} is missing an id — using deterministic fallback "tool_use_${index}"`,
212+
);
213+
}
214+
return {
215+
id: b.id ?? `tool_use_${index}`,
216+
type: "function" as const,
217+
function: {
218+
name: b.name ?? "",
219+
arguments: typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? {}),
220+
},
221+
};
222+
}),
195223
});
196224
} else {
197-
messages.push({ role: "assistant", content: textContent || null });
225+
messages.push({ role: "assistant", content: textContent ?? null });
198226
}
199227
} else {
200228
messages.push({ role: "assistant", content: null });
201229
}
230+
} else {
231+
if (logger) {
232+
logger.warn(
233+
`Bedrock message has unexpected role "${(msg as { role: string }).role}" — skipping`,
234+
);
235+
}
202236
}
203237
}
204238

@@ -347,7 +381,7 @@ export async function handleBedrock(
347381
}
348382

349383
// Convert to ChatCompletionRequest for fixture matching
350-
const completionReq = bedrockToCompletionRequest(bedrockReq, modelId);
384+
const completionReq = bedrockToCompletionRequest(bedrockReq, modelId, logger);
351385
completionReq._endpointType = "chat";
352386

353387
const testId = getTestId(req);
@@ -449,7 +483,8 @@ export async function handleBedrock(
449483
body: completionReq,
450484
response: { status, fixture },
451485
});
452-
// Anthropic-style error format (Bedrock uses Claude): { type: "error", error: { type, message } }
486+
// Bedrock Claude error format: { type: "error", error: { type, message } }
487+
// Uses ?? (nullish coalescing) intentionally — preserves explicit empty-string types from fixtures.
453488
const anthropicError = {
454489
type: "error",
455490
error: {
@@ -526,6 +561,9 @@ export async function handleBedrock(
526561

527562
// Tool call response
528563
if (isToolCallResponse(response)) {
564+
if ("webSearches" in response) {
565+
logger.warn("webSearches in fixture response are not supported for Bedrock API — ignoring");
566+
}
529567
const overrides = extractOverrides(response);
530568
journal.add({
531569
method: req.method ?? "POST",
@@ -938,7 +976,8 @@ export async function handleBedrockStream(
938976
return;
939977
}
940978

941-
const completionReq = bedrockToCompletionRequest(bedrockReq, modelId);
979+
const completionReq = bedrockToCompletionRequest(bedrockReq, modelId, logger);
980+
completionReq.stream = true;
942981
completionReq._endpointType = "chat";
943982

944983
const testId = getTestId(req);
@@ -1042,7 +1081,8 @@ export async function handleBedrockStream(
10421081
body: completionReq,
10431082
response: { status, fixture },
10441083
});
1045-
// Anthropic-style error format (Bedrock uses Claude): { type: "error", error: { type, message } }
1084+
// Bedrock Claude error format: { type: "error", error: { type, message } }
1085+
// Uses ?? (nullish coalescing) intentionally — preserves explicit empty-string types from fixtures.
10461086
const anthropicError = {
10471087
type: "error",
10481088
error: {
@@ -1130,6 +1170,9 @@ export async function handleBedrockStream(
11301170

11311171
// Tool call response — stream as Event Stream
11321172
if (isToolCallResponse(response)) {
1173+
if ("webSearches" in response) {
1174+
logger.warn("webSearches in fixture response are not supported for Bedrock API — ignoring");
1175+
}
11331176
const overrides = extractOverrides(response);
11341177
const journalEntry = journal.add({
11351178
method: req.method ?? "POST",

0 commit comments

Comments
 (0)