修改: app/api/[provider]/[...path]/route.ts

修改:     app/api/auth.ts
	新文件:   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/settings.tsx
	修改:     app/config/server.ts
	修改:     app/constant.ts
	修改:     app/locales/cn.ts
	修改:     app/locales/en.ts
	修改:     app/store/access.ts
	修改:     app/utils.ts
	修改:     package.json
This commit is contained in:
glay 2024-10-29 22:20:26 +08:00
parent 613d67eada
commit ff356f0c8c
15 changed files with 1261 additions and 13 deletions

View File

@ -1,6 +1,7 @@
import { ApiPath } from "@/app/constant"; import { ApiPath } from "@/app/constant";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { handle as openaiHandler } from "../../openai"; import { handle as openaiHandler } from "../../openai";
import { handle as bedrockHandler } from "../../bedrock";
import { handle as azureHandler } from "../../azure"; import { handle as azureHandler } from "../../azure";
import { handle as googleHandler } from "../../google"; import { handle as googleHandler } from "../../google";
import { handle as anthropicHandler } from "../../anthropic"; import { handle as anthropicHandler } from "../../anthropic";
@ -20,12 +21,15 @@ async function handle(
const apiPath = `/api/${params.provider}`; const apiPath = `/api/${params.provider}`;
console.log(`[${params.provider} Route] params `, params); console.log(`[${params.provider} Route] params `, params);
switch (apiPath) { switch (apiPath) {
case ApiPath.Bedrock:
return bedrockHandler(req, { params });
case ApiPath.Azure: case ApiPath.Azure:
return azureHandler(req, { params }); return azureHandler(req, { params });
case ApiPath.Google: case ApiPath.Google:
return googleHandler(req, { params }); return googleHandler(req, { params });
case ApiPath.Anthropic: case ApiPath.Anthropic:
return anthropicHandler(req, { params }); return anthropicHandler(req, { params });
case ApiPath.Baidu: case ApiPath.Baidu:
return baiduHandler(req, { params }); return baiduHandler(req, { params });
case ApiPath.ByteDance: case ApiPath.ByteDance:

View File

@ -52,6 +52,28 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
msg: "you are not allowed to access with your own api key", msg: "you are not allowed to access with your own api key",
}; };
} }
// Special handling for Bedrock
if (modelProvider === ModelProvider.Bedrock) {
const region = req.headers.get("X-Region");
const accessKeyId = req.headers.get("X-Access-Key");
const secretKey = req.headers.get("X-Secret-Key");
console.log("[Auth] Bedrock credentials:", {
region,
accessKeyId: accessKeyId ? "***" : undefined,
secretKey: secretKey ? "***" : undefined,
});
// Check if AWS credentials are provided
if (!region || !accessKeyId || !secretKey) {
return {
error: true,
msg: "Missing AWS credentials. Please configure Region, Access Key ID, and Secret Access Key in settings.",
};
}
return { error: false };
}
// if user does not provide an api key, inject system api key // if user does not provide an api key, inject system api key
if (!apiKey) { if (!apiKey) {

288
app/api/bedrock.ts Normal file
View File

@ -0,0 +1,288 @@
import { ModelProvider } from "../constant";
import { prettyObject } from "../utils/format";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "./auth";
import {
BedrockRuntimeClient,
InvokeModelCommand,
ValidationException,
} from "@aws-sdk/client-bedrock-runtime";
import { validateModelId } from "./bedrock/utils";
import {
ConverseRequest,
formatRequestBody,
parseModelResponse,
} from "./bedrock/models";
interface ContentItem {
type: string;
text?: string;
image_url?: {
url: string;
};
}
const ALLOWED_PATH = new Set(["invoke", "converse"]);
export async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[Bedrock Route] params ", params);
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const subpath = params.path.join("/");
if (!ALLOWED_PATH.has(subpath)) {
console.log("[Bedrock Route] forbidden path ", subpath);
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, ModelProvider.Bedrock);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
if (subpath === "converse") {
const response = await handleConverseRequest(req);
return response;
} else {
const response = await handleInvokeRequest(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,
message: e instanceof Error ? e.message : "Unknown error",
details: prettyObject(e),
},
{ status: 500 },
);
}
}
async function handleConverseRequest(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,
},
);
}
console.log("[Bedrock] Using region:", region);
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()) as ConverseRequest;
const { modelId } = body;
// Validate model ID
const validationError = validateModelId(modelId);
if (validationError) {
throw new ValidationException({
message: validationError,
$metadata: {},
});
}
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 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: {},
});
}
console.log("[Bedrock] Invoking model:", model);
console.log("[Bedrock] Messages:", messages);
const requestBody = formatRequestBody({
modelId: model,
messages,
inferenceConfig: {
maxTokens: 2048,
temperature: 0.7,
topP: 0.9,
},
});
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, {
headers: {
"Content-Type": "text/plain",
},
});
} catch (e) {
console.error("[Bedrock] Request error:", e);
throw e;
} finally {
clearTimeout(timeoutId);
}
}

280
app/api/bedrock/models.ts Normal file
View File

@ -0,0 +1,280 @@
import {
Message,
validateMessageOrder,
processDocumentContent,
BedrockTextBlock,
BedrockImageBlock,
BedrockDocumentBlock,
} from "./utils";
export interface ConverseRequest {
modelId: string;
messages: Message[];
inferenceConfig?: {
maxTokens?: number;
temperature?: number;
topP?: number;
};
system?: string;
tools?: Array<{
type: "function";
function: {
name: string;
description: string;
parameters: {
type: string;
properties: Record<string, any>;
required: string[];
};
};
}>;
}
interface ContentItem {
type: string;
text?: string;
image_url?: {
url: string;
};
document?: {
format: string;
name: string;
source: {
bytes: string;
};
};
}
type ProcessedContent =
| ContentItem
| BedrockTextBlock
| BedrockImageBlock
| BedrockDocumentBlock
| {
type: string;
source: { type: string; media_type: string; data: string };
};
// 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,
};
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:"],
},
};
}
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) {
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,
};
}
// 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
return {
role: "assistant",
content: [{ type: "text", text: responseBody }],
};
}
}

218
app/api/bedrock/utils.ts Normal file
View File

@ -0,0 +1,218 @@
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
}
export interface BedrockImageBlock {
image: {
format: "png" | "jpeg" | "gif" | "webp";
source: ImageSource;
};
}
export interface BedrockDocumentBlock {
document: {
format:
| "pdf"
| "csv"
| "doc"
| "docx"
| "xls"
| "xlsx"
| "html"
| "txt"
| "md";
name: string;
source: DocumentSource;
};
}
export interface BedrockTextBlock {
text: string;
}
export type BedrockContentBlock =
| BedrockTextBlock
| BedrockImageBlock
| BedrockDocumentBlock;
export interface BedrockResponse {
content?: any[];
completion?: string;
stop_reason?: string;
usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
tool_calls?: any[];
}
// 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 process document content for Bedrock
export function processDocumentContent(content: any): BedrockContentBlock {
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 (
!["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"].includes(
format,
)
) {
throw new Error(`Unsupported document format: ${format}`);
}
return {
document: {
format: format as BedrockDocumentBlock["document"]["format"],
name: sanitizeDocumentName(content.document.name),
source: {
bytes: content.document.source.bytes,
},
},
};
}
// 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],
},
},
});
}
}
} 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 formattedContent;
}
// 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) {
if (message.role === lastRole) {
// Skip duplicate roles to maintain alternation
continue;
}
validatedMessages.push(message);
lastRole = message.role;
}
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,
): string | MultimodalContent[] {
if (response.completion) {
return response.completion;
}
if (!response.content) {
return "";
}
return response.content.map((block) => {
if ("text" in block) {
return {
type: "text",
text: block.text,
};
} else if ("image" in block) {
return {
type: "image_url",
image_url: {
url: `data:image/${block.image.format};base64,${block.image.source.bytes}`,
},
};
}
// Document responses are converted to text content
return {
type: "text",
text: block.text || "",
};
});
}

View File

@ -12,6 +12,7 @@ import {
useChatStore, useChatStore,
} from "../store"; } from "../store";
import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai"; import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
import { BedrockApi } from "./platforms/bedrock";
import { GeminiProApi } from "./platforms/google"; import { GeminiProApi } from "./platforms/google";
import { ClaudeApi } from "./platforms/anthropic"; import { ClaudeApi } from "./platforms/anthropic";
import { ErnieApi } from "./platforms/baidu"; import { ErnieApi } from "./platforms/baidu";
@ -129,6 +130,9 @@ export class ClientApi {
constructor(provider: ModelProvider = ModelProvider.GPT) { constructor(provider: ModelProvider = ModelProvider.GPT) {
switch (provider) { switch (provider) {
case ModelProvider.Bedrock:
this.llm = new BedrockApi();
break;
case ModelProvider.GeminiPro: case ModelProvider.GeminiPro:
this.llm = new GeminiProApi(); this.llm = new GeminiProApi();
break; break;
@ -235,6 +239,7 @@ export function getHeaders(ignoreHeaders: boolean = false) {
function getConfig() { function getConfig() {
const modelConfig = chatStore.currentSession().mask.modelConfig; const modelConfig = chatStore.currentSession().mask.modelConfig;
const isBedrock = modelConfig.providerName === ServiceProvider.Bedrock;
const isGoogle = modelConfig.providerName === ServiceProvider.Google; const isGoogle = modelConfig.providerName === ServiceProvider.Google;
const isAzure = modelConfig.providerName === ServiceProvider.Azure; const isAzure = modelConfig.providerName === ServiceProvider.Azure;
const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic; const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic;
@ -247,6 +252,8 @@ export function getHeaders(ignoreHeaders: boolean = false) {
const isEnabledAccessControl = accessStore.enabledAccessControl(); const isEnabledAccessControl = accessStore.enabledAccessControl();
const apiKey = isGoogle const apiKey = isGoogle
? accessStore.googleApiKey ? accessStore.googleApiKey
: isBedrock
? accessStore.awsAccessKeyId // Use AWS access key for Bedrock
: isAzure : isAzure
? accessStore.azureApiKey ? accessStore.azureApiKey
: isAnthropic : isAnthropic
@ -265,6 +272,7 @@ export function getHeaders(ignoreHeaders: boolean = false) {
: "" : ""
: accessStore.openaiApiKey; : accessStore.openaiApiKey;
return { return {
isBedrock,
isGoogle, isGoogle,
isAzure, isAzure,
isAnthropic, isAnthropic,
@ -286,10 +294,13 @@ export function getHeaders(ignoreHeaders: boolean = false) {
? "x-api-key" ? "x-api-key"
: isGoogle : isGoogle
? "x-goog-api-key" ? "x-goog-api-key"
: isBedrock
? "x-api-key"
: "Authorization"; : "Authorization";
} }
const { const {
isBedrock,
isGoogle, isGoogle,
isAzure, isAzure,
isAnthropic, isAnthropic,
@ -302,17 +313,27 @@ export function getHeaders(ignoreHeaders: boolean = false) {
const authHeader = getAuthHeader(); const authHeader = getAuthHeader();
const bearerToken = getBearerToken( if (isBedrock) {
apiKey, // Add AWS credentials for Bedrock
isAzure || isAnthropic || isGoogle, headers["X-Region"] = accessStore.awsRegion;
); headers["X-Access-Key"] = accessStore.awsAccessKeyId;
headers["X-Secret-Key"] = accessStore.awsSecretAccessKey;
if (bearerToken) { if (accessStore.awsSessionToken) {
headers[authHeader] = bearerToken; headers["X-Session-Token"] = accessStore.awsSessionToken;
} else if (isEnabledAccessControl && validString(accessStore.accessCode)) { }
headers["Authorization"] = getBearerToken( } else {
ACCESS_CODE_PREFIX + accessStore.accessCode, const bearerToken = getBearerToken(
apiKey,
isAzure || isAnthropic || isGoogle,
); );
if (bearerToken) {
headers[authHeader] = bearerToken;
} else if (isEnabledAccessControl && validString(accessStore.accessCode)) {
headers["Authorization"] = getBearerToken(
ACCESS_CODE_PREFIX + accessStore.accessCode,
);
}
} }
return headers; return headers;
@ -320,6 +341,8 @@ export function getHeaders(ignoreHeaders: boolean = false) {
export function getClientApi(provider: ServiceProvider): ClientApi { export function getClientApi(provider: ServiceProvider): ClientApi {
switch (provider) { switch (provider) {
case ServiceProvider.Bedrock:
return new ClientApi(ModelProvider.Bedrock);
case ServiceProvider.Google: case ServiceProvider.Google:
return new ClientApi(ModelProvider.GeminiPro); return new ClientApi(ModelProvider.GeminiPro);
case ServiceProvider.Anthropic: case ServiceProvider.Anthropic:

View File

@ -0,0 +1,223 @@
import { ApiPath } from "../../constant";
import { ChatOptions, getHeaders, LLMApi, SpeechOptions } from "../api";
import {
useAccessStore,
useAppConfig,
useChatStore,
usePluginStore,
} from "../../store";
import { preProcessImageContent, stream } from "../../utils/chat";
import Locale from "../../locales";
export interface BedrockChatRequest {
model: string;
messages: Array<{
role: string;
content:
| string
| Array<{
type: string;
text?: string;
image_url?: { url: string };
document?: {
format: string;
name: string;
source: {
bytes: string;
};
};
}>;
}>;
temperature?: number;
top_p?: number;
max_tokens?: number;
stream?: boolean;
}
export class BedrockApi implements LLMApi {
speech(options: SpeechOptions): Promise<ArrayBuffer> {
throw new Error("Method not implemented.");
}
extractMessage(res: any) {
console.log("[Response] bedrock response: ", res);
return res;
}
async chat(options: ChatOptions): Promise<void> {
const shouldStream = !!options.config.stream;
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
},
};
const accessStore = useAccessStore.getState();
if (
!accessStore.awsRegion ||
!accessStore.awsAccessKeyId ||
!accessStore.awsSecretAccessKey
) {
console.log("AWS credentials are not set");
let responseText = "";
const responseTexts = [responseText];
responseTexts.push(Locale.Error.Unauthorized);
responseText = responseTexts.join("\n\n");
options.onFinish(responseText);
return;
}
// Process messages to handle image and document content
const messages = await Promise.all(
options.messages.map(async (v) => {
const content = await preProcessImageContent(v.content);
// If content is an array (multimodal), ensure each item is properly formatted
if (Array.isArray(content)) {
return {
role: v.role,
content: content.map((item) => {
if (item.type === "image_url" && item.image_url?.url) {
// If the URL is a base64 data URL, use it directly
if (item.image_url.url.startsWith("data:image/")) {
return item;
}
// Otherwise, it's a regular URL that needs to be converted to base64
// The conversion should have been handled by preProcessImageContent
return item;
}
if ("document" in item) {
// Handle document content
const doc = item as any;
if (
doc?.document?.format &&
doc?.document?.name &&
doc?.document?.source?.bytes
) {
return {
type: "document",
document: {
format: doc.document.format,
name: doc.document.name,
source: {
bytes: doc.document.source.bytes,
},
},
};
}
}
return item;
}),
};
}
// If content is a string, return it as is
return {
role: v.role,
content,
};
}),
);
const requestBody: BedrockChatRequest = {
messages,
stream: shouldStream,
model: modelConfig.model,
max_tokens: modelConfig.max_tokens,
temperature: modelConfig.temperature,
top_p: modelConfig.top_p,
};
console.log("[Bedrock] Request:", {
model: modelConfig.model,
messages: messages,
});
const controller = new AbortController();
options.onController?.(controller);
const headers: Record<string, string> = {
...getHeaders(),
"X-Region": accessStore.awsRegion,
"X-Access-Key": accessStore.awsAccessKeyId,
"X-Secret-Key": accessStore.awsSecretAccessKey,
};
if (accessStore.awsSessionToken) {
headers["X-Session-Token"] = accessStore.awsSessionToken;
}
try {
if (shouldStream) {
let responseText = "";
const pluginStore = usePluginStore.getState();
const currentSession = useChatStore.getState().currentSession();
const [tools, funcs] = pluginStore.getAsTools(
currentSession.mask?.plugin || [],
);
await stream(
`${ApiPath.Bedrock}/invoke`,
requestBody,
headers,
Array.isArray(tools) ? tools : [],
funcs || {},
controller,
(chunk: string) => {
try {
responseText += chunk;
return chunk;
} catch (e) {
console.error("[Request] parse error", chunk, e);
return "";
}
},
(
requestPayload: any,
toolCallMessage: any,
toolCallResult: any[],
) => {
console.log("[Bedrock] processToolMessage", {
requestPayload,
toolCallMessage,
toolCallResult,
});
},
options,
);
} else {
const response = await fetch(`${ApiPath.Bedrock}/invoke`, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
signal: controller.signal,
});
if (!response.ok) {
const error = await response.text();
console.error("[Bedrock] Error response:", error);
throw new Error(`Bedrock API error: ${error}`);
}
const text = await response.text();
options.onFinish(text);
}
} catch (e) {
console.error("[Bedrock] Chat error:", e);
options.onError?.(e as Error);
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models() {
return [];
}
}

View File

@ -963,7 +963,75 @@ export function Settings() {
</ListItem> </ListItem>
</> </>
); );
const bedrockConfigComponent = accessStore.provider ===
ServiceProvider.Bedrock && (
<>
<ListItem
title={Locale.Settings.Access.Bedrock.Region.Title}
subTitle={Locale.Settings.Access.Bedrock.Region.SubTitle}
>
<input
aria-label={Locale.Settings.Access.Bedrock.Region.Title}
type="text"
value={accessStore.awsRegion}
placeholder="us-west-2"
onChange={(e) =>
accessStore.update(
(access) => (access.awsRegion = e.currentTarget.value),
)
}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Bedrock.AccessKey.Title}
subTitle={Locale.Settings.Access.Bedrock.AccessKey.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Bedrock.AccessKey.Title}
value={accessStore.awsAccessKeyId}
type="text"
placeholder={Locale.Settings.Access.Bedrock.AccessKey.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.awsAccessKeyId = e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Bedrock.SecretKey.Title}
subTitle={Locale.Settings.Access.Bedrock.SecretKey.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Bedrock.SecretKey.Title}
value={accessStore.awsSecretAccessKey}
type="text"
placeholder={Locale.Settings.Access.Bedrock.SecretKey.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.awsSecretAccessKey = e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Bedrock.SessionToken.Title}
subTitle={Locale.Settings.Access.Bedrock.SessionToken.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Bedrock.SessionToken.Title}
value={accessStore.awsSessionToken}
type="text"
placeholder={Locale.Settings.Access.Bedrock.SessionToken.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.awsSessionToken = e.currentTarget.value),
);
}}
/>
</ListItem>
</>
);
const baiduConfigComponent = accessStore.provider === const baiduConfigComponent = accessStore.provider ===
ServiceProvider.Baidu && ( ServiceProvider.Baidu && (
<> <>
@ -1682,6 +1750,7 @@ export function Settings() {
</ListItem> </ListItem>
{openAIConfigComponent} {openAIConfigComponent}
{bedrockConfigComponent}
{azureConfigComponent} {azureConfigComponent}
{googleConfigComponent} {googleConfigComponent}
{anthropicConfigComponent} {anthropicConfigComponent}

View File

@ -12,6 +12,10 @@ declare global {
BASE_URL?: string; BASE_URL?: string;
OPENAI_ORG_ID?: string; // openai only OPENAI_ORG_ID?: string; // openai only
// bedrock only
BEDROCK_URL?: string;
BEDROCK_API_KEY?: string;
VERCEL?: string; VERCEL?: string;
BUILD_MODE?: "standalone" | "export"; BUILD_MODE?: "standalone" | "export";
BUILD_APP?: string; // is building desktop app BUILD_APP?: string; // is building desktop app
@ -139,7 +143,7 @@ export const getServerSideConfig = () => {
} }
const isStability = !!process.env.STABILITY_API_KEY; const isStability = !!process.env.STABILITY_API_KEY;
const isBedrock = !!process.env.BEDROCK_API_KEY;
const isAzure = !!process.env.AZURE_URL; const isAzure = !!process.env.AZURE_URL;
const isGoogle = !!process.env.GOOGLE_API_KEY; const isGoogle = !!process.env.GOOGLE_API_KEY;
const isAnthropic = !!process.env.ANTHROPIC_API_KEY; const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
@ -168,6 +172,10 @@ export const getServerSideConfig = () => {
apiKey: getApiKey(process.env.OPENAI_API_KEY), apiKey: getApiKey(process.env.OPENAI_API_KEY),
openaiOrgId: process.env.OPENAI_ORG_ID, openaiOrgId: process.env.OPENAI_ORG_ID,
isBedrock,
bedrockUrl: process.env.BEDROCK_URL,
bedrockApiKey: getApiKey(process.env.BEDROCK_API_KEY),
isStability, isStability,
stabilityUrl: process.env.STABILITY_URL, stabilityUrl: process.env.STABILITY_URL,
stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY), stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),

View File

@ -12,6 +12,8 @@ export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
export const STABILITY_BASE_URL = "https://api.stability.ai"; export const STABILITY_BASE_URL = "https://api.stability.ai";
export const OPENAI_BASE_URL = "https://api.openai.com"; export const OPENAI_BASE_URL = "https://api.openai.com";
export const BEDROCK_BASE_URL =
"https://bedrock-runtime.us-west-2.amazonaws.com";
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com"; export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/"; export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/";
@ -49,6 +51,7 @@ export enum Path {
export enum ApiPath { export enum ApiPath {
Cors = "", Cors = "",
Bedrock = "/api/bedrock",
Azure = "/api/azure", Azure = "/api/azure",
OpenAI = "/api/openai", OpenAI = "/api/openai",
Anthropic = "/api/anthropic", Anthropic = "/api/anthropic",
@ -115,6 +118,7 @@ export enum ServiceProvider {
Stability = "Stability", Stability = "Stability",
Iflytek = "Iflytek", Iflytek = "Iflytek",
XAI = "XAI", XAI = "XAI",
Bedrock = "Bedrock",
} }
// Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings // Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings
@ -128,6 +132,7 @@ export enum GoogleSafetySettingsThreshold {
export enum ModelProvider { export enum ModelProvider {
Stability = "Stability", Stability = "Stability",
Bedrock = "Bedrock",
GPT = "GPT", GPT = "GPT",
GeminiPro = "GeminiPro", GeminiPro = "GeminiPro",
Claude = "Claude", Claude = "Claude",
@ -304,6 +309,26 @@ const openaiModels = [
"o1-preview", "o1-preview",
]; ];
const bedrockModels = [
// Claude Models
"anthropic.claude-3-haiku-20240307-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-opus-20240229-v1:0",
"anthropic.claude-3-5-sonnet-20240620-v1:0",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
// Amazon Titan Models
"amazon.titan-text-express-v1",
"amazon.titan-text-lite-v1",
// Meta Llama Models
"meta.llama3-2-1b-instruct-v1:0",
"meta.llama3-2-3b-instruct-v1:0",
"meta.llama3-2-11b-instruct-v1:0",
//Mistral
"mistral.mistral-7b-instruct-v0:2",
"mistral.mixtral-8x7b-instruct-v0:1",
"mistral.mistral-large-2407-v1:0",
];
const googleModels = [ const googleModels = [
"gemini-1.0-pro", "gemini-1.0-pro",
"gemini-1.5-pro-latest", "gemini-1.5-pro-latest",
@ -499,6 +524,17 @@ export const DEFAULT_MODELS = [
sorted: 11, sorted: 11,
}, },
})), })),
...bedrockModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "bedrock",
providerName: "Bedrock",
providerType: "bedrock",
sorted: 12,
},
})),
] as const; ] as const;
export const CHAT_PAGE_SIZE = 15; export const CHAT_PAGE_SIZE = 15;

View File

@ -342,6 +342,32 @@ const cn = {
SubTitle: "除默认地址外,必须包含 http(s)://", SubTitle: "除默认地址外,必须包含 http(s)://",
}, },
}, },
Bedrock: {
Region: {
Title: "AWS Region",
SubTitle: "The AWS region where Bedrock service is located",
Placeholder: "us-west-2",
},
AccessKey: {
Title: "AWS Access Key ID",
SubTitle: "Your AWS access key ID for Bedrock service",
Placeholder: "AKIA...",
},
SecretKey: {
Title: "AWS Secret Access Key",
SubTitle: "Your AWS secret access key for Bedrock service",
Placeholder: "****",
},
SessionToken: {
Title: "AWS Session Token (Optional)",
SubTitle: "Your AWS session token if using temporary credentials",
Placeholder: "Optional session token",
},
Endpoint: {
Title: "AWS Bedrock Endpoint",
SubTitle: "Custom endpoint for AWS Bedrock API. Default: ",
},
},
Azure: { Azure: {
ApiKey: { ApiKey: {
Title: "接口密钥", Title: "接口密钥",

View File

@ -346,6 +346,32 @@ const en: LocaleType = {
SubTitle: "Must start with http(s):// or use /api/openai as default", SubTitle: "Must start with http(s):// or use /api/openai as default",
}, },
}, },
Bedrock: {
Region: {
Title: "AWS Region",
SubTitle: "The AWS region where Bedrock service is located",
Placeholder: "us-west-2",
},
AccessKey: {
Title: "AWS Access Key ID",
SubTitle: "Your AWS access key ID for Bedrock service",
Placeholder: "AKIA...",
},
SecretKey: {
Title: "AWS Secret Access Key",
SubTitle: "Your AWS secret access key for Bedrock service",
Placeholder: "****",
},
SessionToken: {
Title: "AWS Session Token (Optional)",
SubTitle: "Your AWS session token if using temporary credentials",
Placeholder: "Optional session token",
},
Endpoint: {
Title: "AWS Bedrock Endpoint",
SubTitle: "Custom endpoint for AWS Bedrock API. Default: ",
},
},
Azure: { Azure: {
ApiKey: { ApiKey: {
Title: "Azure Api Key", Title: "Azure Api Key",

View File

@ -4,6 +4,7 @@ import {
StoreKey, StoreKey,
ApiPath, ApiPath,
OPENAI_BASE_URL, OPENAI_BASE_URL,
BEDROCK_BASE_URL,
ANTHROPIC_BASE_URL, ANTHROPIC_BASE_URL,
GEMINI_BASE_URL, GEMINI_BASE_URL,
BAIDU_BASE_URL, BAIDU_BASE_URL,
@ -26,6 +27,7 @@ let fetchState = 0; // 0 not fetch, 1 fetching, 2 done
const isApp = getClientConfig()?.buildMode === "export"; const isApp = getClientConfig()?.buildMode === "export";
const DEFAULT_OPENAI_URL = isApp ? OPENAI_BASE_URL : ApiPath.OpenAI; const DEFAULT_OPENAI_URL = isApp ? OPENAI_BASE_URL : ApiPath.OpenAI;
const DEFAULT_BEDROCK_URL = isApp ? BEDROCK_BASE_URL : ApiPath.Bedrock;
const DEFAULT_GOOGLE_URL = isApp ? GEMINI_BASE_URL : ApiPath.Google; const DEFAULT_GOOGLE_URL = isApp ? GEMINI_BASE_URL : ApiPath.Google;
@ -57,6 +59,16 @@ const DEFAULT_ACCESS_STATE = {
openaiUrl: DEFAULT_OPENAI_URL, openaiUrl: DEFAULT_OPENAI_URL,
openaiApiKey: "", openaiApiKey: "",
// bedrock
bedrockUrl: DEFAULT_BEDROCK_URL,
bedrockApiKey: "",
awsRegion: "",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsSessionToken: "",
awsCognitoUser: false,
awsInferenceProfile: "", // Added inference profile field
// azure // azure
azureUrl: "", azureUrl: "",
azureApiKey: "", azureApiKey: "",
@ -141,6 +153,14 @@ export const useAccessStore = createPersistStore(
return ensure(get(), ["openaiApiKey"]); return ensure(get(), ["openaiApiKey"]);
}, },
isValidBedrock() {
return ensure(get(), [
"awsAccessKeyId",
"awsSecretAccessKey",
"awsRegion",
]);
},
isValidAzure() { isValidAzure() {
return ensure(get(), ["azureUrl", "azureApiKey", "azureApiVersion"]); return ensure(get(), ["azureUrl", "azureApiKey", "azureApiVersion"]);
}, },
@ -186,6 +206,7 @@ export const useAccessStore = createPersistStore(
// has token or has code or disabled access control // has token or has code or disabled access control
return ( return (
this.isValidOpenAI() || this.isValidOpenAI() ||
this.isValidBedrock() ||
this.isValidAzure() || this.isValidAzure() ||
this.isValidGoogle() || this.isValidGoogle() ||
this.isValidAnthropic() || this.isValidAnthropic() ||

View File

@ -285,6 +285,9 @@ export function showPlugins(provider: ServiceProvider, model: string) {
if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) { if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) {
return true; return true;
} }
if (provider == ServiceProvider.Bedrock && !model.includes("claude-2")) {
return true;
}
if (provider == ServiceProvider.Google && !model.includes("vision")) { if (provider == ServiceProvider.Google && !model.includes("vision")) {
return true; return true;
} }

View File

@ -51,7 +51,8 @@
"sass": "^1.59.2", "sass": "^1.59.2",
"spark-md5": "^3.0.2", "spark-md5": "^3.0.2",
"use-debounce": "^9.0.4", "use-debounce": "^9.0.4",
"zustand": "^4.3.8" "zustand": "^4.3.8",
"@aws-sdk/client-bedrock-runtime": "^3.679.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^1.6.0",