Compare commits

...

1 Commits

Author SHA1 Message Date
arvinxx 0cfc890ab6 try to fix ssrf 2025-11-03 19:39:27 +08:00
11 changed files with 158 additions and 27 deletions
@@ -65,7 +65,7 @@ describe('anthropicHelpers', () => {
const result = await buildAnthropicBlock(content);
expect(parseDataUri).toHaveBeenCalledWith(content.image_url.url);
expect(imageUrlToBase64).toHaveBeenCalledWith(content.image_url.url);
expect(imageUrlToBase64).toHaveBeenCalledWith(content.image_url.url, undefined);
expect(result).toEqual({
source: {
data: 'convertedBase64String',
@@ -76,6 +76,28 @@ describe('anthropicHelpers', () => {
});
});
it('should pass custom fetch to imageUrlToBase64', async () => {
vi.mocked(parseDataUri).mockReturnValueOnce({
mimeType: 'image/png',
base64: null,
type: 'url',
});
vi.mocked(imageUrlToBase64).mockResolvedValue({
base64: 'convertedBase64String',
mimeType: 'image/jpg',
});
const content = {
type: 'image_url',
image_url: { url: 'https://example.com/image.png' },
} as const;
const customFetch = vi.fn() as any;
await buildAnthropicBlock(content, customFetch);
expect(imageUrlToBase64).toHaveBeenCalledWith(content.image_url.url, customFetch);
});
it('should use default media_type for URL images when mimeType is not provided', async () => {
vi.mocked(parseDataUri).mockReturnValueOnce({
mimeType: null,
@@ -7,6 +7,7 @@ import { parseDataUri } from '../../utils/uriParser';
export const buildAnthropicBlock = async (
content: UserMessageContentPart,
customFetch?: typeof fetch,
): Promise<Anthropic.ContentBlock | Anthropic.ImageBlockParam | undefined> => {
switch (content.type) {
case 'thinking': {
@@ -34,7 +35,7 @@ export const buildAnthropicBlock = async (
};
if (type === 'url') {
const { base64, mimeType } = await imageUrlToBase64(content.image_url.url);
const { base64, mimeType } = await imageUrlToBase64(content.image_url.url, customFetch);
return {
source: {
data: base64 as string,
@@ -50,9 +51,11 @@ export const buildAnthropicBlock = async (
}
};
const buildArrayContent = async (content: UserMessageContentPart[]) => {
const buildArrayContent = async (content: UserMessageContentPart[], customFetch?: typeof fetch) => {
let messageContent = (await Promise.all(
(content as UserMessageContentPart[]).map(async (c) => await buildAnthropicBlock(c)),
(content as UserMessageContentPart[]).map(
async (c) => await buildAnthropicBlock(c, customFetch),
),
)) as Anthropic.Messages.ContentBlockParam[];
messageContent = messageContent.filter(Boolean);
@@ -62,6 +65,7 @@ const buildArrayContent = async (content: UserMessageContentPart[]) => {
export const buildAnthropicMessage = async (
message: OpenAIChatMessage,
customFetch?: typeof fetch,
): Promise<Anthropic.Messages.MessageParam> => {
const content = message.content as string | UserMessageContentPart[];
@@ -72,7 +76,8 @@ export const buildAnthropicMessage = async (
case 'user': {
return {
content: typeof content === 'string' ? content : await buildArrayContent(content),
content:
typeof content === 'string' ? content : await buildArrayContent(content, customFetch),
role: 'user',
};
}
@@ -100,7 +105,7 @@ export const buildAnthropicMessage = async (
? ([{ text: message.content, type: 'text' }] as UserMessageContentPart[])
: content;
const messageContent = await buildArrayContent(rawContent);
const messageContent = await buildArrayContent(rawContent, customFetch);
return {
content: [
@@ -129,7 +134,7 @@ export const buildAnthropicMessage = async (
export const buildAnthropicMessages = async (
oaiMessages: OpenAIChatMessage[],
options: { enabledContextCaching?: boolean } = {},
options: { customFetch?: typeof fetch; enabledContextCaching?: boolean } = {},
): Promise<Anthropic.Messages.MessageParam[]> => {
const messages: Anthropic.Messages.MessageParam[] = [];
let pendingToolResults: Anthropic.ToolResultBlockParam[] = [];
@@ -175,7 +180,7 @@ export const buildAnthropicMessages = async (
});
}
} else {
const anthropicMessage = await buildAnthropicMessage(message);
const anthropicMessage = await buildAnthropicMessage(message, options.customFetch);
messages.push({ ...anthropicMessage, role: anthropicMessage.role });
}
}
@@ -52,7 +52,28 @@ describe('convertMessageContent', () => {
});
expect(parseDataUri).toHaveBeenCalledWith('https://example.com/image.jpg');
expect(imageUrlToBase64).toHaveBeenCalledWith('https://example.com/image.jpg');
expect(imageUrlToBase64).toHaveBeenCalledWith('https://example.com/image.jpg', undefined);
});
it('should pass custom fetch to imageUrlToBase64', async () => {
process.env.LLM_VISION_IMAGE_USE_BASE64 = '1';
const content = {
type: 'image_url',
image_url: { url: 'https://example.com/image.jpg' },
} as OpenAI.ChatCompletionContentPart;
const customFetch = vi.fn() as any;
vi.mocked(parseDataUri).mockReturnValue({ type: 'url', base64: null, mimeType: null });
vi.mocked(imageUrlToBase64).mockResolvedValue({
base64: 'base64String',
mimeType: 'image/jpeg',
});
await convertMessageContent(content, customFetch);
expect(imageUrlToBase64).toHaveBeenCalledWith('https://example.com/image.jpg', customFetch);
});
it('should not convert image URL when not necessary', async () => {
@@ -427,6 +448,28 @@ describe('convertImageUrlToFile', () => {
expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
});
it('should use custom fetch when provided', async () => {
const mockArrayBuffer = new ArrayBuffer(8);
const mockHeaders = new Headers();
mockHeaders.set('content-type', 'image/png');
const customFetch = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
headers: mockHeaders,
});
const result = await convertImageUrlToFile(
'https://example.com/custom.png',
customFetch as any,
);
expect(customFetch).toHaveBeenCalledWith('https://example.com/custom.png');
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toHaveProperty('name', 'image.png');
expect(result).toHaveProperty('type', 'image/png');
});
});
describe('Edge cases', () => {
@@ -7,12 +7,13 @@ import { parseDataUri } from '../../utils/uriParser';
export const convertMessageContent = async (
content: OpenAI.ChatCompletionContentPart,
customFetch?: typeof fetch,
): Promise<OpenAI.ChatCompletionContentPart> => {
if (content.type === 'image_url') {
const { type } = parseDataUri(content.image_url.url);
if (type === 'url' && process.env.LLM_VISION_IMAGE_USE_BASE64 === '1') {
const { base64, mimeType } = await imageUrlToBase64(content.image_url.url);
const { base64, mimeType } = await imageUrlToBase64(content.image_url.url, customFetch);
return {
...content,
@@ -24,7 +25,10 @@ export const convertMessageContent = async (
return content;
};
export const convertOpenAIMessages = async (messages: OpenAI.ChatCompletionMessageParam[]) => {
export const convertOpenAIMessages = async (
messages: OpenAI.ChatCompletionMessageParam[],
customFetch?: typeof fetch,
) => {
return (await Promise.all(
messages.map(async (message) => ({
...message,
@@ -33,7 +37,7 @@ export const convertOpenAIMessages = async (messages: OpenAI.ChatCompletionMessa
? message.content
: await Promise.all(
(message.content || []).map((c) =>
convertMessageContent(c as OpenAI.ChatCompletionContentPart),
convertMessageContent(c as OpenAI.ChatCompletionContentPart, customFetch),
),
),
})),
@@ -42,6 +46,7 @@ export const convertOpenAIMessages = async (messages: OpenAI.ChatCompletionMessa
export const convertOpenAIResponseInputs = async (
messages: OpenAI.ChatCompletionMessageParam[],
customFetch?: typeof fetch,
) => {
let input: OpenAI.Responses.ResponseInputItem[] = [];
await Promise.all(
@@ -83,7 +88,10 @@ export const convertOpenAIResponseInputs = async (
return { ...c, type: 'input_text' };
}
const image = await convertMessageContent(c as OpenAI.ChatCompletionContentPart);
const image = await convertMessageContent(
c as OpenAI.ChatCompletionContentPart,
customFetch,
);
return {
image_url: (image as OpenAI.ChatCompletionContentPartImage).image_url?.url,
type: 'input_image',
@@ -127,7 +135,7 @@ export const pruneReasoningPayload = (payload: ChatStreamPayload) => {
/**
* Convert image URL (data URL or HTTP URL) to File object for OpenAI API
*/
export const convertImageUrlToFile = async (imageUrl: string) => {
export const convertImageUrlToFile = async (imageUrl: string, customFetch?: typeof fetch) => {
let buffer: Buffer;
let mimeType: string;
@@ -138,7 +146,8 @@ export const convertImageUrlToFile = async (imageUrl: string) => {
buffer = Buffer.from(base64Data, 'base64');
} else {
// a http url
const response = await fetch(imageUrl);
const fetchFn = customFetch || fetch;
const response = await fetchFn(imageUrl);
if (!response.ok) {
throw new Error(`Failed to fetch image from ${imageUrl}: ${response.statusText}`);
}
@@ -19,6 +19,7 @@ async function generateByImageMode(
client: OpenAI,
payload: CreateImagePayload,
provider: string,
customFetch?: typeof fetch,
): Promise<CreateImageResponse> {
const { model, params } = payload;
@@ -48,7 +49,7 @@ async function generateByImageMode(
try {
// Convert all image URLs to File objects
const imageFiles = await Promise.all(
userInput.image.map((url: string) => convertImageUrlToFile(url)),
userInput.image.map((url: string) => convertImageUrlToFile(url, customFetch)),
);
// According to official docs, if there are multiple images, pass an array; if only one, pass a single File
@@ -127,7 +128,10 @@ async function generateByImageMode(
/**
* Process image URL for chat model input
*/
async function processImageUrlForChat(imageUrl: string): Promise<string> {
async function processImageUrlForChat(
imageUrl: string,
customFetch?: typeof fetch,
): Promise<string> {
const { type, base64, mimeType } = parseDataUri(imageUrl);
if (type === 'base64') {
@@ -137,7 +141,10 @@ async function processImageUrlForChat(imageUrl: string): Promise<string> {
return `data:${mimeType || 'image/png'};base64,${base64}`;
} else if (type === 'url') {
// For URL type, convert to base64 first
const { base64: urlBase64, mimeType: urlMimeType } = await imageUrlToBase64(imageUrl);
const { base64: urlBase64, mimeType: urlMimeType } = await imageUrlToBase64(
imageUrl,
customFetch,
);
return `data:${urlMimeType};base64,${urlBase64}`;
} else {
throw new TypeError(`Currently we don't support image url: ${imageUrl}`);
@@ -150,6 +157,7 @@ async function processImageUrlForChat(imageUrl: string): Promise<string> {
async function generateByChatModel(
client: OpenAI,
payload: CreateImagePayload,
customFetch?: typeof fetch,
): Promise<CreateImageResponse> {
const { model, params } = payload;
const actualModel = model.replace(':image', ''); // Remove :image suffix
@@ -168,7 +176,7 @@ async function generateByChatModel(
if (params.imageUrl && params.imageUrl !== null) {
log('Processing image URL for editing mode: %s', params.imageUrl);
try {
const processedImageUrl = await processImageUrlForChat(params.imageUrl);
const processedImageUrl = await processImageUrlForChat(params.imageUrl, customFetch);
content.push({
image_url: {
url: processedImageUrl,
@@ -224,14 +232,15 @@ export async function createOpenAICompatibleImage(
client: OpenAI,
payload: CreateImagePayload,
provider: string,
customFetch?: typeof fetch,
): Promise<CreateImageResponse> {
const { model } = payload;
// Check if it's a chat model for image generation (via :image suffix)
if (model.endsWith(':image')) {
return await generateByChatModel(client, payload);
return await generateByChatModel(client, payload, customFetch);
}
// Default to traditional images API
return await generateByImageMode(client, payload, provider);
return await generateByImageMode(client, payload, provider, customFetch);
}
@@ -162,6 +162,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
baseURL!: string;
protected _options: ConstructorOptions<T>;
protected _fetch?: typeof fetch;
constructor(options: ClientOptions & Record<string, any> = {}) {
const _options = {
@@ -172,6 +173,9 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
const { apiKey, baseURL = DEFAULT_BASE_URL, ...res } = _options;
this._options = _options as ConstructorOptions<T>;
// Store custom fetch if provided
this._fetch = options.fetch as any;
if (!apiKey) throw AgentRuntimeError.createError(ErrorType?.invalidAPIKey);
const initOptions = { apiKey, baseURL, ...constructorOptions, ...res };
@@ -229,7 +233,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
return this.handleResponseAPIMode(processedPayload, options);
}
const messages = await convertOpenAIMessages(postPayload.messages);
const messages = await convertOpenAIMessages(postPayload.messages, this._fetch);
let response: Stream<OpenAI.Chat.Completions.ChatCompletionChunk>;
@@ -335,7 +339,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
}
// Use the new createOpenAICompatibleImage function
return createOpenAICompatibleImage(this.client, payload, this.id);
return createOpenAICompatibleImage(this.client, payload, this.id, this._fetch);
}
async models() {
@@ -604,7 +608,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
delete res.frequency_penalty;
delete res.presence_penalty;
const input = await convertOpenAIResponseInputs(messages as any);
const input = await convertOpenAIResponseInputs(messages as any, this._fetch);
const isStreaming = payload.stream !== false;
@@ -73,6 +73,7 @@ const resolveCacheTTL = (
};
interface AnthropicAIParams extends ClientOptions {
fetch?: typeof fetch;
id?: string;
}
@@ -82,6 +83,7 @@ export class LobeAnthropicAI implements LobeRuntimeAI {
baseURL: string;
apiKey?: string;
private id: string;
private _fetch?: typeof fetch;
private isDebug() {
return process.env.DEBUG_ANTHROPIC_CHAT_COMPLETION === '1';
@@ -92,16 +94,20 @@ export class LobeAnthropicAI implements LobeRuntimeAI {
baseURL = DEFAULT_BASE_URL,
id,
defaultHeaders,
fetch: customFetch,
...res
}: AnthropicAIParams = {}) {
if (!apiKey) throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidProviderAPIKey);
const betaHeaders = process.env.ANTHROPIC_BETA_HEADERS;
this._fetch = customFetch;
this.client = new Anthropic({
apiKey,
baseURL,
defaultHeaders: { ...defaultHeaders, 'anthropic-beta': betaHeaders },
fetch: customFetch,
...res,
});
this.baseURL = this.client.baseURL;
@@ -200,7 +206,10 @@ export class LobeAnthropicAI implements LobeRuntimeAI {
] as Anthropic.TextBlockParam[])
: undefined;
const postMessages = await buildAnthropicMessages(user_messages, { enabledContextCaching });
const postMessages = await buildAnthropicMessages(user_messages, {
customFetch: this._fetch,
enabledContextCaching,
});
let postTools: anthropicTools[] | undefined = buildAnthropicTools(tools, {
enabledContextCaching,
@@ -88,4 +88,28 @@ describe('imageUrlToBase64', () => {
await expect(imageUrlToBase64('https://example.com/image.jpg')).rejects.toThrow('Fetch failed');
});
it('should use custom fetch when provided', async () => {
const customFetch = vi.fn().mockResolvedValue({
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
blob: () => Promise.resolve(new Blob([mockArrayBuffer], { type: 'image/png' })),
});
const result = await imageUrlToBase64('https://example.com/image.png', customFetch as any);
expect(customFetch).toHaveBeenCalledWith('https://example.com/image.png');
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toEqual({ base64: 'mockBase64String', mimeType: 'image/png' });
});
it('should use global fetch when custom fetch is not provided', async () => {
mockFetch.mockResolvedValue({
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
blob: () => Promise.resolve(new Blob([mockArrayBuffer], { type: 'image/jpg' })),
});
await imageUrlToBase64('https://example.com/image.jpg');
expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
});
});
@@ -38,9 +38,11 @@ export const imageToBase64 = ({
export const imageUrlToBase64 = async (
imageUrl: string,
customFetch?: typeof fetch,
): Promise<{ base64: string; mimeType: string }> => {
try {
const res = await fetch(imageUrl);
const fetchFn = customFetch || fetch;
const res = await fetchFn(imageUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
+3 -1
View File
@@ -38,9 +38,11 @@ export const imageToBase64 = ({
export const imageUrlToBase64 = async (
imageUrl: string,
customFetch?: typeof fetch,
): Promise<{ base64: string; mimeType: string }> => {
try {
const res = await fetch(imageUrl);
const fetchFn = customFetch || fetch;
const res = await fetchFn(imageUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
+2
View File
@@ -1,6 +1,7 @@
import { ModelRuntime } from '@lobechat/model-runtime';
import { ClientSecretPayload } from '@lobechat/types';
import { ModelProvider } from 'model-bank';
import { ssrfSafeFetch } from 'ssrf-safe-fetch';
import { getLLMConfig } from '@/envs/llm';
@@ -133,5 +134,6 @@ export const initModelRuntimeWithUserPayload = (
return ModelRuntime.initializeWithProvider(runtimeProvider, {
...getParamsFromPayload(runtimeProvider, payload),
...params,
fetch: ssrfSafeFetch as any,
});
};