修改: app/api/bedrock.ts

修改:     app/api/bedrock/models.ts
	修改:     app/api/bedrock/utils.ts
	修改:     app/client/api.ts
	修改:     app/client/platforms/bedrock.ts
	新文件:   app/components/chat-actions.tsx
	修改:     app/components/chat.module.scss
	修改:     app/components/chat.tsx
	修改:     app/constant.ts
	新文件:   app/icons/document.svg
	修改:     app/locales/cn.ts
	修改:     app/locales/en.ts
This commit is contained in:
glay
2024-10-31 14:23:38 +08:00
parent 722c28839f
commit dca4a0e48f
12 changed files with 1435 additions and 658 deletions

View File

@@ -4,25 +4,17 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "./auth";
import {
BedrockRuntimeClient,
InvokeModelCommand,
ConverseStreamOutput,
ValidationException,
ModelStreamErrorException,
ThrottlingException,
ServiceUnavailableException,
InternalServerException,
} from "@aws-sdk/client-bedrock-runtime";
import { validateModelId } from "./bedrock/utils";
import {
ConverseRequest,
formatRequestBody,
parseModelResponse,
} from "./bedrock/models";
import { ConverseRequest, createConverseStreamCommand } from "./bedrock/models";
interface ContentItem {
type: string;
text?: string;
image_url?: {
url: string;
};
}
const ALLOWED_PATH = new Set(["invoke", "converse"]);
const ALLOWED_PATH = new Set(["converse"]);
export async function handle(
req: NextRequest,
@@ -57,29 +49,10 @@ export async function handle(
}
try {
if (subpath === "converse") {
const response = await handleConverseRequest(req);
return response;
} else {
const response = await handleInvokeRequest(req);
return response;
}
const response = await handleConverseRequest(req);
return response;
} catch (e) {
console.error("[Bedrock] ", e);
// Handle specific error cases
if (e instanceof ValidationException) {
return NextResponse.json(
{
error: true,
message:
"Model validation error. If using a Llama model, please provide a valid inference profile ARN.",
details: e.message,
},
{ status: 400 },
);
}
return NextResponse.json(
{
error: true,
@@ -92,9 +65,7 @@ export async function handle(
}
async function handleConverseRequest(req: NextRequest) {
const controller = new AbortController();
const region = req.headers.get("X-Region") || "us-east-1";
const region = req.headers.get("X-Region") || "us-west-2";
const accessKeyId = req.headers.get("X-Access-Key") || "";
const secretAccessKey = req.headers.get("X-Secret-Key") || "";
const sessionToken = req.headers.get("X-Session-Token");
@@ -111,8 +82,6 @@ async function handleConverseRequest(req: NextRequest) {
);
}
console.log("[Bedrock] Using region:", region);
const client = new BedrockRuntimeClient({
region,
credentials: {
@@ -122,167 +91,171 @@ async function handleConverseRequest(req: NextRequest) {
},
});
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
try {
const body = (await req.json()) as ConverseRequest;
const { modelId } = body;
// Validate model ID
const validationError = validateModelId(modelId);
if (validationError) {
throw new ValidationException({
message: validationError,
$metadata: {},
});
throw new Error(validationError);
}
console.log("[Bedrock] Invoking model:", modelId);
console.log("[Bedrock] Messages:", body.messages);
const requestBody = formatRequestBody(body);
const jsonString = JSON.stringify(requestBody);
const input = {
modelId,
contentType: "application/json",
accept: "application/json",
body: Uint8Array.from(Buffer.from(jsonString)),
};
console.log("[Bedrock] Request input:", {
...input,
body: requestBody,
});
const command = new InvokeModelCommand(input);
const command = createConverseStreamCommand(body);
const response = await client.send(command);
console.log("[Bedrock] Got response");
// Parse and format the response based on model type
const responseBody = new TextDecoder().decode(response.body);
const formattedResponse = parseModelResponse(responseBody, modelId);
return NextResponse.json(formattedResponse);
} catch (e) {
console.error("[Bedrock] Request error:", e);
throw e; // Let the main error handler deal with it
} finally {
clearTimeout(timeoutId);
}
}
async function handleInvokeRequest(req: NextRequest) {
const controller = new AbortController();
const region = req.headers.get("X-Region") || "us-east-1";
const accessKeyId = req.headers.get("X-Access-Key") || "";
const secretAccessKey = req.headers.get("X-Secret-Key") || "";
const sessionToken = req.headers.get("X-Session-Token");
if (!accessKeyId || !secretAccessKey) {
return NextResponse.json(
{
error: true,
message: "Missing AWS credentials",
},
{
status: 401,
},
);
}
const client = new BedrockRuntimeClient({
region,
credentials: {
accessKeyId,
secretAccessKey,
sessionToken: sessionToken || undefined,
},
});
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
try {
const body = await req.json();
const { messages, model } = body;
// Validate model ID
const validationError = validateModelId(model);
if (validationError) {
throw new ValidationException({
message: validationError,
$metadata: {},
});
if (!response.stream) {
throw new Error("No stream in response");
}
console.log("[Bedrock] Invoking model:", model);
console.log("[Bedrock] Messages:", messages);
// Create a ReadableStream for the response
const stream = new ReadableStream({
async start(controller) {
try {
const responseStream = response.stream;
if (!responseStream) {
throw new Error("No stream in response");
}
const requestBody = formatRequestBody({
modelId: model,
messages,
inferenceConfig: {
maxTokens: 2048,
temperature: 0.7,
topP: 0.9,
for await (const event of responseStream) {
const output = event as ConverseStreamOutput;
if ("messageStart" in output && output.messageStart?.role) {
controller.enqueue(
`data: ${JSON.stringify({
type: "messageStart",
role: output.messageStart.role,
})}\n\n`,
);
} else if (
"contentBlockStart" in output &&
output.contentBlockStart
) {
controller.enqueue(
`data: ${JSON.stringify({
type: "contentBlockStart",
index: output.contentBlockStart.contentBlockIndex,
start: output.contentBlockStart.start,
})}\n\n`,
);
} else if (
"contentBlockDelta" in output &&
output.contentBlockDelta?.delta
) {
if ("text" in output.contentBlockDelta.delta) {
controller.enqueue(
`data: ${JSON.stringify({
type: "text",
content: output.contentBlockDelta.delta.text,
})}\n\n`,
);
} else if ("toolUse" in output.contentBlockDelta.delta) {
controller.enqueue(
`data: ${JSON.stringify({
type: "toolUse",
input: output.contentBlockDelta.delta.toolUse?.input,
})}\n\n`,
);
}
} else if (
"contentBlockStop" in output &&
output.contentBlockStop
) {
controller.enqueue(
`data: ${JSON.stringify({
type: "contentBlockStop",
index: output.contentBlockStop.contentBlockIndex,
})}\n\n`,
);
} else if ("messageStop" in output && output.messageStop) {
controller.enqueue(
`data: ${JSON.stringify({
type: "messageStop",
stopReason: output.messageStop.stopReason,
additionalModelResponseFields:
output.messageStop.additionalModelResponseFields,
})}\n\n`,
);
} else if ("metadata" in output && output.metadata) {
controller.enqueue(
`data: ${JSON.stringify({
type: "metadata",
usage: output.metadata.usage,
metrics: output.metadata.metrics,
trace: output.metadata.trace,
})}\n\n`,
);
}
}
controller.close();
} catch (error) {
if (error instanceof ValidationException) {
controller.enqueue(
`data: ${JSON.stringify({
type: "error",
error: "ValidationException",
message: error.message,
})}\n\n`,
);
} else if (error instanceof ModelStreamErrorException) {
controller.enqueue(
`data: ${JSON.stringify({
type: "error",
error: "ModelStreamErrorException",
message: error.message,
originalStatusCode: error.originalStatusCode,
originalMessage: error.originalMessage,
})}\n\n`,
);
} else if (error instanceof ThrottlingException) {
controller.enqueue(
`data: ${JSON.stringify({
type: "error",
error: "ThrottlingException",
message: error.message,
})}\n\n`,
);
} else if (error instanceof ServiceUnavailableException) {
controller.enqueue(
`data: ${JSON.stringify({
type: "error",
error: "ServiceUnavailableException",
message: error.message,
})}\n\n`,
);
} else if (error instanceof InternalServerException) {
controller.enqueue(
`data: ${JSON.stringify({
type: "error",
error: "InternalServerException",
message: error.message,
})}\n\n`,
);
} else {
controller.enqueue(
`data: ${JSON.stringify({
type: "error",
error: "UnknownError",
message:
error instanceof Error ? error.message : "Unknown error",
})}\n\n`,
);
}
controller.close();
}
},
});
const jsonString = JSON.stringify(requestBody);
const input = {
modelId: model,
contentType: "application/json",
accept: "application/json",
body: Uint8Array.from(Buffer.from(jsonString)),
};
console.log("[Bedrock] Request input:", {
...input,
body: requestBody,
});
const command = new InvokeModelCommand(input);
const response = await client.send(command);
console.log("[Bedrock] Got response");
// Parse and format the response
const responseBody = new TextDecoder().decode(response.body);
const formattedResponse = parseModelResponse(responseBody, model);
// Extract text content from the response
let textContent = "";
if (formattedResponse.content && Array.isArray(formattedResponse.content)) {
textContent = formattedResponse.content
.filter((item: ContentItem) => item.type === "text")
.map((item: ContentItem) => item.text || "")
.join("");
} else if (typeof formattedResponse.content === "string") {
textContent = formattedResponse.content;
}
// Return plain text response
return new NextResponse(textContent, {
return new Response(stream, {
headers: {
"Content-Type": "text/plain",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (e) {
console.error("[Bedrock] Request error:", e);
throw e;
} finally {
clearTimeout(timeoutId);
} catch (error) {
console.error("[Bedrock] Request error:", error);
throw error;
}
}

View File

@@ -1,280 +1,405 @@
import {
Message,
validateMessageOrder,
processDocumentContent,
BedrockTextBlock,
BedrockImageBlock,
BedrockDocumentBlock,
} from "./utils";
ConverseStreamCommand,
type ConverseStreamCommandInput,
type Message,
type ContentBlock,
type SystemContentBlock,
type Tool,
type ToolChoice,
type ToolResultContentBlock,
} from "@aws-sdk/client-bedrock-runtime";
export interface ConverseRequest {
modelId: string;
messages: Message[];
messages: {
role: "user" | "assistant" | "system";
content: string | ContentItem[];
}[];
inferenceConfig?: {
maxTokens?: number;
temperature?: number;
topP?: number;
stopSequences?: string[];
};
toolConfig?: {
tools: Tool[];
toolChoice?: ToolChoice;
};
system?: string;
tools?: Array<{
type: "function";
function: {
name: string;
description: string;
parameters: {
type: string;
properties: Record<string, any>;
required: string[];
};
};
}>;
}
interface ContentItem {
type: string;
type: "text" | "image_url" | "document" | "tool_use" | "tool_result";
text?: string;
image_url?: {
url: string;
url: string; // base64 data URL
};
document?: {
format: string;
format:
| "pdf"
| "csv"
| "doc"
| "docx"
| "xls"
| "xlsx"
| "html"
| "txt"
| "md";
name: string;
source: {
bytes: string;
bytes: string; // base64
};
};
tool_use?: {
tool_use_id: string;
name: string;
input: any;
};
tool_result?: {
tool_use_id: string;
content: ToolResultItem[];
status: "success" | "error";
};
}
type ProcessedContent =
| ContentItem
| BedrockTextBlock
| BedrockImageBlock
| BedrockDocumentBlock
| {
type: string;
source: { type: string; media_type: string; data: string };
interface ToolResultItem {
type: "text" | "image" | "document" | "json";
text?: string;
image?: {
format: "png" | "jpeg" | "gif" | "webp";
source: {
bytes: string; // base64
};
// Helper function to format request body based on model type
export function formatRequestBody(request: ConverseRequest) {
const baseModel = request.modelId;
const messages = validateMessageOrder(request.messages).map((msg) => ({
role: msg.role,
content: Array.isArray(msg.content)
? msg.content.map((item: ContentItem) => {
if (item.type === "image_url" && item.image_url?.url) {
// If it's a base64 image URL
const base64Match = item.image_url.url.match(
/^data:image\/([a-zA-Z]*);base64,([^"]*)$/,
);
if (base64Match) {
return {
type: "image",
source: {
type: "base64",
media_type: `image/${base64Match[1]}`,
data: base64Match[2],
},
};
}
// If it's not a base64 URL, return as is
return item;
}
if ("document" in item) {
try {
return processDocumentContent(item);
} catch (error) {
console.error("Error processing document:", error);
return {
type: "text",
text: `[Document: ${item.document?.name || "Unknown"}]`,
};
}
}
return { type: "text", text: item.text };
})
: [{ type: "text", text: msg.content }],
}));
const systemPrompt = request.system
? [{ type: "text", text: request.system }]
: undefined;
const baseConfig = {
max_tokens: request.inferenceConfig?.maxTokens || 2048,
temperature: request.inferenceConfig?.temperature || 0.7,
top_p: request.inferenceConfig?.topP || 0.9,
};
document?: {
format:
| "pdf"
| "csv"
| "doc"
| "docx"
| "xls"
| "xlsx"
| "html"
| "txt"
| "md";
name: string;
source: {
bytes: string; // base64
};
};
json?: any;
}
if (baseModel.startsWith("anthropic.claude")) {
return {
messages,
system: systemPrompt,
anthropic_version: "bedrock-2023-05-31",
...baseConfig,
...(request.tools && { tools: request.tools }),
};
} else if (
baseModel.startsWith("meta.llama") ||
baseModel.startsWith("mistral.")
) {
return {
messages: messages.map((m) => ({
role: m.role,
content: Array.isArray(m.content)
? m.content.map((c: ProcessedContent) => {
if ("text" in c) return { type: "text", text: c.text || "" };
if ("image_url" in c)
return {
type: "text",
text: `[Image: ${c.image_url?.url || "URL not provided"}]`,
};
if ("document" in c)
return {
type: "text",
text: `[Document: ${c.document?.name || "Unknown"}]`,
};
return { type: "text", text: "" };
})
: [{ type: "text", text: m.content }],
})),
...baseConfig,
stop_sequences: ["\n\nHuman:", "\n\nAssistant:"],
};
} else if (baseModel.startsWith("amazon.titan")) {
const formattedText = messages.map((m) => ({
role: m.role,
content: [
{
type: "text",
text: `${m.role === "user" ? "Human" : "Assistant"}: ${
Array.isArray(m.content)
? m.content
.map((c: ProcessedContent) => {
if ("text" in c) return c.text || "";
if ("image_url" in c)
return `[Image: ${
c.image_url?.url || "URL not provided"
}]`;
if ("document" in c)
return `[Document: ${c.document?.name || "Unknown"}]`;
return "";
})
.join("")
: m.content
}`,
},
],
}));
return {
messages: formattedText,
textGenerationConfig: {
maxTokenCount: baseConfig.max_tokens,
temperature: baseConfig.temperature,
topP: baseConfig.top_p,
stopSequences: ["Human:", "Assistant:"],
},
};
function convertContentToAWSBlock(item: ContentItem): ContentBlock | null {
if (item.type === "text" && item.text) {
return { text: item.text };
}
throw new Error(`Unsupported model: ${baseModel}`);
}
// Helper function to parse and format response based on model type
export function parseModelResponse(responseBody: string, modelId: string): any {
const baseModel = modelId;
try {
const response = JSON.parse(responseBody);
// Common response format for all models
const formatResponse = (content: string | any[]) => ({
role: "assistant",
content: Array.isArray(content)
? content.map((item) => {
if (typeof item === "string") {
return { type: "text", text: item };
}
// Handle different content types
if ("text" in item) {
return { type: "text", text: item.text || "" };
}
if ("image" in item) {
return {
type: "image_url",
image_url: {
url: `data:image/${
item.source?.media_type || "image/png"
};base64,${item.source?.data || ""}`,
},
};
}
// Document responses are converted to text
if ("document" in item) {
return {
type: "text",
text: `[Document Content]\n${item.text || ""}`,
};
}
return { type: "text", text: item.text || "" };
})
: [{ type: "text", text: content }],
stop_reason: response.stop_reason || response.stopReason || "end_turn",
usage: response.usage || {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
},
});
if (baseModel.startsWith("anthropic.claude")) {
// Handle the new Converse API response format
if (response.output?.message) {
if (item.type === "image_url" && item.image_url?.url) {
const base64Match = item.image_url.url.match(
/^data:image\/([a-zA-Z]*);base64,([^"]*)/,
);
if (base64Match) {
const format = base64Match[1].toLowerCase();
if (
format === "png" ||
format === "jpeg" ||
format === "gif" ||
format === "webp"
) {
const base64Data = base64Match[2];
return {
role: response.output.message.role,
content: response.output.message.content.map((item: any) => {
if ("text" in item) return { type: "text", text: item.text || "" };
if ("image" in item) {
return {
type: "image_url",
image_url: {
url: `data:${item.source?.media_type || "image/png"};base64,${
item.source?.data || ""
}`,
},
};
}
return { type: "text", text: item.text || "" };
}),
stop_reason: response.stopReason,
usage: response.usage,
image: {
format: format as "png" | "jpeg" | "gif" | "webp",
source: {
bytes: Uint8Array.from(Buffer.from(base64Data, "base64")),
},
},
};
}
// Fallback for older format
return formatResponse(
response.content ||
(response.completion
? [{ type: "text", text: response.completion }]
: []),
);
} else if (baseModel.startsWith("meta.llama")) {
return formatResponse(response.generation || response.completion || "");
} else if (baseModel.startsWith("amazon.titan")) {
return formatResponse(response.results?.[0]?.outputText || "");
} else if (baseModel.startsWith("mistral.")) {
return formatResponse(
response.outputs?.[0]?.text || response.response || "",
);
}
}
throw new Error(`Unsupported model: ${baseModel}`);
} catch (e) {
console.error("[Bedrock] Failed to parse response:", e);
// Return raw text as fallback
if (item.type === "document" && item.document) {
return {
role: "assistant",
content: [{ type: "text", text: responseBody }],
document: {
format: item.document.format,
name: item.document.name,
source: {
bytes: Uint8Array.from(
Buffer.from(item.document.source.bytes, "base64"),
),
},
},
};
}
if (item.type === "tool_use" && item.tool_use) {
return {
toolUse: {
toolUseId: item.tool_use.tool_use_id,
name: item.tool_use.name,
input: item.tool_use.input,
},
};
}
if (item.type === "tool_result" && item.tool_result) {
const toolResultContent = item.tool_result.content
.map((resultItem) => {
if (resultItem.type === "text" && resultItem.text) {
return { text: resultItem.text } as ToolResultContentBlock;
}
if (resultItem.type === "image" && resultItem.image) {
return {
image: {
format: resultItem.image.format,
source: {
bytes: Uint8Array.from(
Buffer.from(resultItem.image.source.bytes, "base64"),
),
},
},
} as ToolResultContentBlock;
}
if (resultItem.type === "document" && resultItem.document) {
return {
document: {
format: resultItem.document.format,
name: resultItem.document.name,
source: {
bytes: Uint8Array.from(
Buffer.from(resultItem.document.source.bytes, "base64"),
),
},
},
} as ToolResultContentBlock;
}
if (resultItem.type === "json" && resultItem.json) {
return { json: resultItem.json } as ToolResultContentBlock;
}
return null;
})
.filter((content): content is ToolResultContentBlock => content !== null);
if (toolResultContent.length === 0) {
return null;
}
return {
toolResult: {
toolUseId: item.tool_result.tool_use_id,
content: toolResultContent,
status: item.tool_result.status,
},
};
}
return null;
}
function convertContentToAWS(content: string | ContentItem[]): ContentBlock[] {
if (typeof content === "string") {
return [{ text: content }];
}
// Filter out null blocks and ensure each content block is valid
const blocks = content
.map(convertContentToAWSBlock)
.filter((block): block is ContentBlock => block !== null);
// If no valid blocks, provide a default text block
if (blocks.length === 0) {
return [{ text: "" }];
}
return blocks;
}
function formatMessages(messages: ConverseRequest["messages"]): {
messages: Message[];
systemPrompt?: SystemContentBlock[];
} {
// Extract system messages
const systemMessages = messages.filter((msg) => msg.role === "system");
const nonSystemMessages = messages.filter((msg) => msg.role !== "system");
// Convert system messages to SystemContentBlock array
const systemPrompt =
systemMessages.length > 0
? systemMessages.map((msg) => {
if (typeof msg.content === "string") {
return { text: msg.content } as SystemContentBlock;
}
// For multimodal content, convert each content item
const blocks = convertContentToAWS(msg.content);
return blocks[0] as SystemContentBlock; // Take first block as system content
})
: undefined;
// Format remaining messages
const formattedMessages = nonSystemMessages.reduce(
(acc: Message[], curr, idx) => {
// Skip if same role as previous message
if (idx > 0 && curr.role === nonSystemMessages[idx - 1].role) {
return acc;
}
const content = convertContentToAWS(curr.content);
if (content.length > 0) {
acc.push({
role: curr.role as "user" | "assistant",
content,
});
}
return acc;
},
[],
);
// Ensure conversation starts with user
if (formattedMessages.length === 0 || formattedMessages[0].role !== "user") {
formattedMessages.unshift({
role: "user",
content: [{ text: "Hello" }],
});
}
// Ensure conversation ends with user
if (formattedMessages[formattedMessages.length - 1].role !== "user") {
formattedMessages.push({
role: "user",
content: [{ text: "Continue" }],
});
}
return { messages: formattedMessages, systemPrompt };
}
export function formatRequestBody(
request: ConverseRequest,
): ConverseStreamCommandInput {
const { messages, systemPrompt } = formatMessages(request.messages);
const input: ConverseStreamCommandInput = {
modelId: request.modelId,
messages,
...(systemPrompt && { system: systemPrompt }),
};
if (request.inferenceConfig) {
input.inferenceConfig = {
maxTokens: request.inferenceConfig.maxTokens,
temperature: request.inferenceConfig.temperature,
topP: request.inferenceConfig.topP,
stopSequences: request.inferenceConfig.stopSequences,
};
}
if (request.toolConfig) {
input.toolConfig = {
tools: request.toolConfig.tools,
toolChoice: request.toolConfig.toolChoice,
};
}
// Create a clean version of the input for logging
const logInput = {
...input,
messages: messages.map((msg) => ({
role: msg.role,
content: msg.content?.map((content) => {
if ("image" in content && content.image) {
return {
image: {
format: content.image.format,
source: { bytes: "[BINARY]" },
},
};
}
if ("document" in content && content.document) {
return {
document: { ...content.document, source: { bytes: "[BINARY]" } },
};
}
return content;
}),
})),
};
console.log(
"[Bedrock] Formatted request:",
JSON.stringify(logInput, null, 2),
);
return input;
}
export function createConverseStreamCommand(request: ConverseRequest) {
const input = formatRequestBody(request);
return new ConverseStreamCommand(input);
}
export interface StreamResponse {
type:
| "messageStart"
| "contentBlockStart"
| "contentBlockDelta"
| "contentBlockStop"
| "messageStop"
| "metadata"
| "error";
role?: string;
index?: number;
start?: any;
delta?: any;
stopReason?: string;
additionalModelResponseFields?: any;
usage?: any;
metrics?: any;
trace?: any;
error?: string;
message?: string;
originalStatusCode?: number;
originalMessage?: string;
}
export function parseStreamResponse(chunk: any): StreamResponse | null {
if (chunk.messageStart) {
return { type: "messageStart", role: chunk.messageStart.role };
}
if (chunk.contentBlockStart) {
return {
type: "contentBlockStart",
index: chunk.contentBlockStart.contentBlockIndex,
start: chunk.contentBlockStart.start,
};
}
if (chunk.contentBlockDelta) {
return {
type: "contentBlockDelta",
index: chunk.contentBlockDelta.contentBlockIndex,
delta: chunk.contentBlockDelta.delta,
};
}
if (chunk.contentBlockStop) {
return {
type: "contentBlockStop",
index: chunk.contentBlockStop.contentBlockIndex,
};
}
if (chunk.messageStop) {
return {
type: "messageStop",
stopReason: chunk.messageStop.stopReason,
additionalModelResponseFields:
chunk.messageStop.additionalModelResponseFields,
};
}
if (chunk.metadata) {
return {
type: "metadata",
usage: chunk.metadata.usage,
metrics: chunk.metadata.metrics,
trace: chunk.metadata.trace,
};
}
return null;
}

View File

@@ -11,43 +11,149 @@ export interface ImageSource {
export interface DocumentSource {
bytes: string; // base64 encoded document bytes
media_type?: string; // MIME type of the document
}
export type DocumentFormat =
| "pdf"
| "csv"
| "doc"
| "docx"
| "xls"
| "xlsx"
| "html"
| "txt"
| "md";
export type ImageFormat = "png" | "jpeg" | "gif" | "webp";
export interface BedrockImageBlock {
type: "image";
image: {
format: "png" | "jpeg" | "gif" | "webp";
source: ImageSource;
format: ImageFormat;
source: {
bytes: string;
};
};
}
export interface BedrockDocumentBlock {
type: "document";
document: {
format:
| "pdf"
| "csv"
| "doc"
| "docx"
| "xls"
| "xlsx"
| "html"
| "txt"
| "md";
format: string;
name: string;
source: DocumentSource;
source: {
bytes: string;
media_type?: string;
};
};
}
export interface BedrockTextBlock {
type: "text";
text: string;
}
export type BedrockContentBlock =
export interface BedrockToolCallBlock {
type: "tool_calls";
tool_calls: BedrockToolCall[];
}
export interface BedrockToolResultBlock {
type: "tool_result";
tool_result: BedrockToolResult;
}
export type BedrockContent =
| BedrockTextBlock
| BedrockImageBlock
| BedrockDocumentBlock;
| BedrockDocumentBlock
| BedrockToolCallBlock
| BedrockToolResultBlock;
export interface BedrockToolSpec {
type: string;
function: {
name: string;
description: string;
parameters: Record<string, any>;
};
}
export interface BedrockToolCall {
type: string;
function: {
name: string;
arguments: string;
};
}
export interface BedrockToolResult {
type: string;
output: string;
}
export interface ContentItem {
type: string;
text?: string;
image_url?: {
url: string;
};
document?: {
format: string;
name: string;
source: {
bytes: string;
media_type?: string;
};
};
tool_calls?: BedrockToolCall[];
tool_result?: BedrockToolResult;
}
export interface StreamEvent {
messageStart?: { role: string };
contentBlockStart?: { index: number };
contentBlockDelta?: {
delta: {
type?: string;
text?: string;
tool_calls?: BedrockToolCall[];
tool_result?: BedrockToolResult;
};
contentBlockIndex: number;
};
contentBlockStop?: { index: number };
messageStop?: { stopReason: string };
metadata?: {
usage: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
metrics: {
latencyMs: number;
};
};
}
export interface ConverseRequest {
modelId: string;
messages: Message[];
inferenceConfig?: {
maxTokens?: number;
temperature?: number;
topP?: number;
stopSequences?: string[];
stream?: boolean;
};
system?: { text: string }[];
tools?: BedrockToolSpec[];
additionalModelRequestFields?: Record<string, any>;
additionalModelResponseFieldPaths?: string[];
}
export interface BedrockResponse {
content?: any[];
content: BedrockContent[];
completion?: string;
stop_reason?: string;
usage?: {
@@ -55,7 +161,7 @@ export interface BedrockResponse {
output_tokens: number;
total_tokens: number;
};
tool_calls?: any[];
tool_calls?: BedrockToolCall[];
}
// Helper function to get the base model type from modelId
@@ -79,8 +185,59 @@ export function validateModelId(modelId: string): string | null {
return null;
}
// Helper function to validate document name
export function validateDocumentName(name: string): boolean {
const validPattern = /^[a-zA-Z0-9\s\-\(\)\[\]]+$/;
const noMultipleSpaces = !/\s{2,}/.test(name);
return validPattern.test(name) && noMultipleSpaces;
}
// Helper function to validate document format
export function validateDocumentFormat(
format: string,
): format is DocumentFormat {
const validFormats: DocumentFormat[] = [
"pdf",
"csv",
"doc",
"docx",
"xls",
"xlsx",
"html",
"txt",
"md",
];
return validFormats.includes(format as DocumentFormat);
}
// Helper function to validate image size and dimensions
export function validateImageSize(base64Data: string): boolean {
// Check size (3.75 MB limit)
const sizeInBytes = (base64Data.length * 3) / 4; // Approximate size of decoded base64
const maxSize = 3.75 * 1024 * 1024; // 3.75 MB in bytes
if (sizeInBytes > maxSize) {
throw new Error("Image size exceeds 3.75 MB limit");
}
return true;
}
// Helper function to validate document size
export function validateDocumentSize(base64Data: string): boolean {
// Check size (4.5 MB limit)
const sizeInBytes = (base64Data.length * 3) / 4; // Approximate size of decoded base64
const maxSize = 4.5 * 1024 * 1024; // 4.5 MB in bytes
if (sizeInBytes > maxSize) {
throw new Error("Document size exceeds 4.5 MB limit");
}
return true;
}
// Helper function to process document content for Bedrock
export function processDocumentContent(content: any): BedrockContentBlock {
export function processDocumentContent(content: any): BedrockDocumentBlock {
if (
!content?.document?.format ||
!content?.document?.name ||
@@ -90,70 +247,90 @@ export function processDocumentContent(content: any): BedrockContentBlock {
}
const format = content.document.format.toLowerCase();
if (
!["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"].includes(
format,
)
) {
if (!validateDocumentFormat(format)) {
throw new Error(`Unsupported document format: ${format}`);
}
if (!validateDocumentName(content.document.name)) {
throw new Error(
`Invalid document name: ${content.document.name}. Only alphanumeric characters, single spaces, hyphens, parentheses, and square brackets are allowed.`,
);
}
// Validate document size
if (!validateDocumentSize(content.document.source.bytes)) {
throw new Error("Document size validation failed");
}
return {
type: "document",
document: {
format: format as BedrockDocumentBlock["document"]["format"],
name: sanitizeDocumentName(content.document.name),
format: format,
name: content.document.name,
source: {
bytes: content.document.source.bytes,
media_type: content.document.source.media_type,
},
},
};
}
// Helper function to format content for Bedrock
export function formatContent(
content: string | MultimodalContent[],
): BedrockContentBlock[] {
if (typeof content === "string") {
return [{ text: content }];
}
const formattedContent: BedrockContentBlock[] = [];
for (const item of content) {
if (item.type === "text" && item.text) {
formattedContent.push({ text: item.text });
} else if (item.type === "image_url" && item.image_url?.url) {
// Extract base64 data from data URL
const base64Match = item.image_url.url.match(
/^data:image\/([a-zA-Z]*);base64,([^"]*)$/,
);
if (base64Match) {
const format = base64Match[1].toLowerCase();
if (["png", "jpeg", "gif", "webp"].includes(format)) {
formattedContent.push({
image: {
format: format as "png" | "jpeg" | "gif" | "webp",
source: {
bytes: base64Match[2],
},
},
});
// Helper function to process image content for Bedrock
export function processImageContent(content: any): BedrockImageBlock {
if (content.type === "image_url" && content.image_url?.url) {
const base64Match = content.image_url.url.match(
/^data:image\/([a-zA-Z]*);base64,([^"]*)$/,
);
if (base64Match) {
const format = base64Match[1].toLowerCase();
if (["png", "jpeg", "gif", "webp"].includes(format)) {
// Validate image size
if (!validateImageSize(base64Match[2])) {
throw new Error("Image size validation failed");
}
}
} else if ("document" in item) {
try {
formattedContent.push(processDocumentContent(item));
} catch (error) {
console.error("Error processing document:", error);
// Convert document to text as fallback
formattedContent.push({
text: `[Document: ${(item as any).document?.name || "Unknown"}]`,
});
return {
type: "image",
image: {
format: format as ImageFormat,
source: {
bytes: base64Match[2],
},
},
};
}
}
}
throw new Error("Invalid image content format");
}
return formattedContent;
// Helper function to validate message content restrictions
export function validateMessageContent(message: Message): void {
if (Array.isArray(message.content)) {
// Count images and documents in user messages
if (message.role === "user") {
const imageCount = message.content.filter(
(item) => item.type === "image_url",
).length;
const documentCount = message.content.filter(
(item) => item.type === "document",
).length;
if (imageCount > 20) {
throw new Error("User messages can include up to 20 images");
}
if (documentCount > 5) {
throw new Error("User messages can include up to 5 documents");
}
} else if (
message.role === "assistant" &&
(message.content.some((item) => item.type === "image_url") ||
message.content.some((item) => item.type === "document"))
) {
throw new Error("Assistant messages cannot include images or documents");
}
}
}
// Helper function to ensure messages alternate between user and assistant
@@ -162,6 +339,9 @@ export function validateMessageOrder(messages: Message[]): Message[] {
let lastRole = "";
for (const message of messages) {
// Validate content restrictions for each message
validateMessageContent(message);
if (message.role === lastRole) {
// Skip duplicate roles to maintain alternation
continue;
@@ -173,16 +353,6 @@ export function validateMessageOrder(messages: Message[]): Message[] {
return validatedMessages;
}
// Helper function to sanitize document names according to Bedrock requirements
function sanitizeDocumentName(name: string): string {
// Remove any characters that aren't alphanumeric, whitespace, hyphens, or parentheses
let sanitized = name.replace(/[^a-zA-Z0-9\s\-\(\)\[\]]/g, "");
// Replace multiple whitespace characters with a single space
sanitized = sanitized.replace(/\s+/g, " ");
// Trim whitespace from start and end
return sanitized.trim();
}
// Helper function to convert Bedrock response back to MultimodalContent format
export function convertBedrockResponseToMultimodal(
response: BedrockResponse,
@@ -196,23 +366,35 @@ export function convertBedrockResponseToMultimodal(
}
return response.content.map((block) => {
if ("text" in block) {
if (block.type === "text") {
return {
type: "text",
text: block.text,
};
} else if ("image" in block) {
} else if (block.type === "image") {
return {
type: "image_url",
image_url: {
url: `data:image/${block.image.format};base64,${block.image.source.bytes}`,
},
};
} else if (block.type === "document") {
return {
type: "document",
document: {
format: block.document.format,
name: block.document.name,
source: {
bytes: block.document.source.bytes,
media_type: block.document.source.media_type,
},
},
};
}
// Document responses are converted to text content
// Fallback to text content
return {
type: "text",
text: block.text || "",
text: "",
};
});
}