mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-10-11 20:43:42 +08:00
修改: app/api/bedrock.ts
删除: app/api/bedrock/models.ts 删除: app/api/bedrock/utils.ts 修改: app/client/platforms/bedrock.ts 删除: app/components/chat-actions.tsx 修改: app/components/chat.tsx 修改: app/constant.ts 删除: app/icons/document.svg
This commit is contained in:
@@ -4,18 +4,350 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "./auth";
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConverseStreamCommand,
|
||||
ConverseStreamCommandInput,
|
||||
ConverseStreamOutput,
|
||||
ValidationException,
|
||||
ModelStreamErrorException,
|
||||
ThrottlingException,
|
||||
ServiceUnavailableException,
|
||||
InternalServerException,
|
||||
type Message,
|
||||
type ContentBlock,
|
||||
type SystemContentBlock,
|
||||
type Tool,
|
||||
type ToolChoice,
|
||||
type ToolResultContentBlock,
|
||||
} from "@aws-sdk/client-bedrock-runtime";
|
||||
import { validateModelId } from "./bedrock/utils";
|
||||
import { ConverseRequest, createConverseStreamCommand } from "./bedrock/models";
|
||||
|
||||
// Constants and Types
|
||||
const ALLOWED_PATH = new Set(["converse"]);
|
||||
|
||||
export interface ConverseRequest {
|
||||
modelId: string;
|
||||
messages: {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string | ContentItem[];
|
||||
}[];
|
||||
inferenceConfig?: {
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
stopSequences?: string[];
|
||||
};
|
||||
toolConfig?: {
|
||||
tools: Tool[];
|
||||
toolChoice?: ToolChoice;
|
||||
};
|
||||
}
|
||||
|
||||
interface ContentItem {
|
||||
type: "text" | "image_url" | "document" | "tool_use" | "tool_result";
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string; // base64 data URL
|
||||
};
|
||||
document?: {
|
||||
format: DocumentFormat;
|
||||
name: string;
|
||||
source: {
|
||||
bytes: string; // base64
|
||||
};
|
||||
};
|
||||
tool_use?: {
|
||||
tool_use_id: string;
|
||||
name: string;
|
||||
input: any;
|
||||
};
|
||||
tool_result?: {
|
||||
tool_use_id: string;
|
||||
content: ToolResultItem[];
|
||||
status: "success" | "error";
|
||||
};
|
||||
}
|
||||
|
||||
interface ToolResultItem {
|
||||
type: "text" | "image" | "document" | "json";
|
||||
text?: string;
|
||||
image?: {
|
||||
format: "png" | "jpeg" | "gif" | "webp";
|
||||
source: {
|
||||
bytes: string; // base64
|
||||
};
|
||||
};
|
||||
document?: {
|
||||
format: DocumentFormat;
|
||||
name: string;
|
||||
source: {
|
||||
bytes: string; // base64
|
||||
};
|
||||
};
|
||||
json?: any;
|
||||
}
|
||||
|
||||
type DocumentFormat =
|
||||
| "pdf"
|
||||
| "csv"
|
||||
| "doc"
|
||||
| "docx"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "html"
|
||||
| "txt"
|
||||
| "md";
|
||||
|
||||
// Validation Functions
|
||||
function validateModelId(modelId: string): string | null {
|
||||
if (
|
||||
modelId.startsWith("meta.llama") &&
|
||||
!modelId.includes("inference-profile")
|
||||
) {
|
||||
return "Llama models require an inference profile. Please use the full inference profile ARN.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateDocumentSize(base64Data: string): boolean {
|
||||
const sizeInBytes = (base64Data.length * 3) / 4;
|
||||
const maxSize = 4.5 * 1024 * 1024;
|
||||
if (sizeInBytes > maxSize) {
|
||||
throw new Error("Document size exceeds 4.5 MB limit");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateImageSize(base64Data: string): boolean {
|
||||
const sizeInBytes = (base64Data.length * 3) / 4;
|
||||
const maxSize = 3.75 * 1024 * 1024;
|
||||
if (sizeInBytes > maxSize) {
|
||||
throw new Error("Image size exceeds 3.75 MB limit");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Content Processing Functions
|
||||
function convertContentToAWSBlock(item: ContentItem): ContentBlock | null {
|
||||
if (item.type === "text" && item.text) {
|
||||
return { text: item.text };
|
||||
}
|
||||
|
||||
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 (["png", "jpeg", "gif", "webp"].includes(format)) {
|
||||
validateImageSize(base64Match[2]);
|
||||
return {
|
||||
image: {
|
||||
format: format as "png" | "jpeg" | "gif" | "webp",
|
||||
source: {
|
||||
bytes: Uint8Array.from(Buffer.from(base64Match[2], "base64")),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === "document" && item.document) {
|
||||
validateDocumentSize(item.document.source.bytes);
|
||||
return {
|
||||
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 }];
|
||||
}
|
||||
|
||||
const blocks = content
|
||||
.map(convertContentToAWSBlock)
|
||||
.filter((block): block is ContentBlock => block !== null);
|
||||
|
||||
return blocks.length > 0 ? blocks : [{ text: "" }];
|
||||
}
|
||||
|
||||
function formatMessages(messages: ConverseRequest["messages"]): {
|
||||
messages: Message[];
|
||||
systemPrompt?: SystemContentBlock[];
|
||||
} {
|
||||
const systemMessages = messages.filter((msg) => msg.role === "system");
|
||||
const nonSystemMessages = messages.filter((msg) => msg.role !== "system");
|
||||
|
||||
const systemPrompt =
|
||||
systemMessages.length > 0
|
||||
? systemMessages.map((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
return { text: msg.content } as SystemContentBlock;
|
||||
}
|
||||
const blocks = convertContentToAWS(msg.content);
|
||||
return blocks[0] as SystemContentBlock;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const formattedMessages = nonSystemMessages.reduce(
|
||||
(acc: Message[], curr, idx) => {
|
||||
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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (formattedMessages.length === 0 || formattedMessages[0].role !== "user") {
|
||||
formattedMessages.unshift({
|
||||
role: "user",
|
||||
content: [{ text: "Hello" }],
|
||||
});
|
||||
}
|
||||
|
||||
if (formattedMessages[formattedMessages.length - 1].role !== "user") {
|
||||
formattedMessages.push({
|
||||
role: "user",
|
||||
content: [{ text: "Continue" }],
|
||||
});
|
||||
}
|
||||
|
||||
return { messages: formattedMessages, systemPrompt };
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Main Request Handler
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
@@ -102,14 +434,13 @@ async function handleConverseRequest(req: NextRequest) {
|
||||
|
||||
console.log("[Bedrock] Invoking model:", modelId);
|
||||
|
||||
const command = createConverseStreamCommand(body);
|
||||
const command = new ConverseStreamCommand(formatRequestBody(body));
|
||||
const response = await client.send(command);
|
||||
|
||||
if (!response.stream) {
|
||||
throw new Error("No stream in response");
|
||||
}
|
||||
|
||||
// Create a ReadableStream for the response
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
@@ -190,58 +521,17 @@ async function handleConverseRequest(req: NextRequest) {
|
||||
}
|
||||
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`,
|
||||
);
|
||||
}
|
||||
const errorResponse = {
|
||||
type: "error",
|
||||
error:
|
||||
error instanceof Error ? error.constructor.name : "UnknownError",
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
...(error instanceof ModelStreamErrorException && {
|
||||
originalStatusCode: error.originalStatusCode,
|
||||
originalMessage: error.originalMessage,
|
||||
}),
|
||||
};
|
||||
controller.enqueue(`data: ${JSON.stringify(errorResponse)}\n\n`);
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
|
@@ -1,405 +0,0 @@
|
||||
import {
|
||||
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: {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string | ContentItem[];
|
||||
}[];
|
||||
inferenceConfig?: {
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
stopSequences?: string[];
|
||||
};
|
||||
toolConfig?: {
|
||||
tools: Tool[];
|
||||
toolChoice?: ToolChoice;
|
||||
};
|
||||
}
|
||||
|
||||
interface ContentItem {
|
||||
type: "text" | "image_url" | "document" | "tool_use" | "tool_result";
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string; // base64 data URL
|
||||
};
|
||||
document?: {
|
||||
format:
|
||||
| "pdf"
|
||||
| "csv"
|
||||
| "doc"
|
||||
| "docx"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "html"
|
||||
| "txt"
|
||||
| "md";
|
||||
name: string;
|
||||
source: {
|
||||
bytes: string; // base64
|
||||
};
|
||||
};
|
||||
tool_use?: {
|
||||
tool_use_id: string;
|
||||
name: string;
|
||||
input: any;
|
||||
};
|
||||
tool_result?: {
|
||||
tool_use_id: string;
|
||||
content: ToolResultItem[];
|
||||
status: "success" | "error";
|
||||
};
|
||||
}
|
||||
|
||||
interface ToolResultItem {
|
||||
type: "text" | "image" | "document" | "json";
|
||||
text?: string;
|
||||
image?: {
|
||||
format: "png" | "jpeg" | "gif" | "webp";
|
||||
source: {
|
||||
bytes: string; // base64
|
||||
};
|
||||
};
|
||||
document?: {
|
||||
format:
|
||||
| "pdf"
|
||||
| "csv"
|
||||
| "doc"
|
||||
| "docx"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "html"
|
||||
| "txt"
|
||||
| "md";
|
||||
name: string;
|
||||
source: {
|
||||
bytes: string; // base64
|
||||
};
|
||||
};
|
||||
json?: any;
|
||||
}
|
||||
|
||||
function convertContentToAWSBlock(item: ContentItem): ContentBlock | null {
|
||||
if (item.type === "text" && item.text) {
|
||||
return { text: item.text };
|
||||
}
|
||||
|
||||
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 {
|
||||
image: {
|
||||
format: format as "png" | "jpeg" | "gif" | "webp",
|
||||
source: {
|
||||
bytes: Uint8Array.from(Buffer.from(base64Data, "base64")),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === "document" && item.document) {
|
||||
return {
|
||||
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;
|
||||
}
|
@@ -1,400 +0,0 @@
|
||||
import { MultimodalContent } from "../../client/api";
|
||||
|
||||
export interface Message {
|
||||
role: string;
|
||||
content: string | MultimodalContent[];
|
||||
}
|
||||
|
||||
export interface ImageSource {
|
||||
bytes: string; // base64 encoded image bytes
|
||||
}
|
||||
|
||||
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: ImageFormat;
|
||||
source: {
|
||||
bytes: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BedrockDocumentBlock {
|
||||
type: "document";
|
||||
document: {
|
||||
format: string;
|
||||
name: string;
|
||||
source: {
|
||||
bytes: string;
|
||||
media_type?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BedrockTextBlock {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface BedrockToolCallBlock {
|
||||
type: "tool_calls";
|
||||
tool_calls: BedrockToolCall[];
|
||||
}
|
||||
|
||||
export interface BedrockToolResultBlock {
|
||||
type: "tool_result";
|
||||
tool_result: BedrockToolResult;
|
||||
}
|
||||
|
||||
export type BedrockContent =
|
||||
| BedrockTextBlock
|
||||
| BedrockImageBlock
|
||||
| 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: BedrockContent[];
|
||||
completion?: string;
|
||||
stop_reason?: string;
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
tool_calls?: BedrockToolCall[];
|
||||
}
|
||||
|
||||
// Helper function to get the base model type from modelId
|
||||
export function getModelType(modelId: string): string {
|
||||
if (modelId.includes("inference-profile")) {
|
||||
const match = modelId.match(/us\.(meta\.llama.+?)$/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return modelId;
|
||||
}
|
||||
|
||||
// Helper function to validate model ID
|
||||
export function validateModelId(modelId: string): string | null {
|
||||
// Check if model requires inference profile
|
||||
if (
|
||||
modelId.startsWith("meta.llama") &&
|
||||
!modelId.includes("inference-profile")
|
||||
) {
|
||||
return "Llama models require an inference profile. Please use the full inference profile ARN.";
|
||||
}
|
||||
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): BedrockDocumentBlock {
|
||||
if (
|
||||
!content?.document?.format ||
|
||||
!content?.document?.name ||
|
||||
!content?.document?.source?.bytes
|
||||
) {
|
||||
throw new Error("Invalid document content format");
|
||||
}
|
||||
|
||||
const format = content.document.format.toLowerCase();
|
||||
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,
|
||||
name: content.document.name,
|
||||
source: {
|
||||
bytes: content.document.source.bytes,
|
||||
media_type: content.document.source.media_type,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "image",
|
||||
image: {
|
||||
format: format as ImageFormat,
|
||||
source: {
|
||||
bytes: base64Match[2],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid image content format");
|
||||
}
|
||||
|
||||
// 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
|
||||
export function validateMessageOrder(messages: Message[]): Message[] {
|
||||
const validatedMessages: 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;
|
||||
}
|
||||
validatedMessages.push(message);
|
||||
lastRole = message.role;
|
||||
}
|
||||
|
||||
return validatedMessages;
|
||||
}
|
||||
|
||||
// Helper function to convert Bedrock response back to MultimodalContent format
|
||||
export function convertBedrockResponseToMultimodal(
|
||||
response: BedrockResponse,
|
||||
): string | MultimodalContent[] {
|
||||
if (response.completion) {
|
||||
return response.completion;
|
||||
}
|
||||
|
||||
if (!response.content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return response.content.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return {
|
||||
type: "text",
|
||||
text: block.text,
|
||||
};
|
||||
} 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// Fallback to text content
|
||||
return {
|
||||
type: "text",
|
||||
text: "",
|
||||
};
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user