diff --git a/app/api/[provider]/[...path]/route.ts b/app/api/[provider]/[...path]/route.ts index e8af34f29f8..8ffc7bbb51e 100644 --- a/app/api/[provider]/[...path]/route.ts +++ b/app/api/[provider]/[...path]/route.ts @@ -16,6 +16,7 @@ import { handle as xaiHandler } from "../../xai"; import { handle as chatglmHandler } from "../../glm"; import { handle as proxyHandler } from "../../proxy"; import { handle as ai302Handler } from "../../302ai"; +import { handle as litellmHandler } from "../../litellm"; async function handle( req: NextRequest, @@ -55,6 +56,8 @@ async function handle( return openaiHandler(req, { params }); case ApiPath["302.AI"]: return ai302Handler(req, { params }); + case ApiPath.LiteLLM: + return litellmHandler(req, { params }); default: return proxyHandler(req, { params }); } diff --git a/app/api/auth.ts b/app/api/auth.ts index 8c78c70c865..1ba22548a0b 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -104,6 +104,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { case ModelProvider.SiliconFlow: systemApiKey = serverConfig.siliconFlowApiKey; break; + case ModelProvider.LiteLLM: + systemApiKey = serverConfig.litellmApiKey; + break; case ModelProvider.GPT: default: if (req.nextUrl.pathname.includes("azure/deployments")) { diff --git a/app/api/litellm.ts b/app/api/litellm.ts new file mode 100644 index 00000000000..c0ebbb58400 --- /dev/null +++ b/app/api/litellm.ts @@ -0,0 +1,123 @@ +import { getServerSideConfig } from "@/app/config/server"; +import { + LITELLM_BASE_URL, + ApiPath, + ModelProvider, + ServiceProvider, +} from "@/app/constant"; +import { prettyObject } from "@/app/utils/format"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/app/api/auth"; +import { isModelNotavailableInServer } from "@/app/utils/model"; + +const serverConfig = getServerSideConfig(); + +export async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[LiteLLM Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const authResult = auth(req, ModelProvider.LiteLLM); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const response = await request(req); + return response; + } catch (e) { + console.error("[LiteLLM] ", e); + return NextResponse.json(prettyObject(e)); + } +} + +async function request(req: NextRequest) { + const controller = new AbortController(); + + let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.LiteLLM, ""); + + let baseUrl = serverConfig.litellmUrl || LITELLM_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + console.log("[Proxy] ", path); + console.log("[Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const fetchUrl = `${baseUrl}${path}`; + const fetchOptions: RequestInit = { + headers: { + "Content-Type": "application/json", + Authorization: req.headers.get("Authorization") ?? "", + }, + method: req.method, + body: req.body, + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + if (serverConfig.customModels && req.body) { + try { + const clonedBody = await req.text(); + fetchOptions.body = clonedBody; + + const jsonBody = JSON.parse(clonedBody) as { model?: string }; + + if ( + isModelNotavailableInServer( + serverConfig.customModels, + jsonBody?.model as string, + ServiceProvider.LiteLLM as string, + ) + ) { + return NextResponse.json( + { + error: true, + message: `you are not allowed to use ${jsonBody?.model} model`, + }, + { + status: 403, + }, + ); + } + } catch (e) { + console.error(`[LiteLLM] filter`, e); + } + } + try { + const res = await fetch(fetchUrl, fetchOptions); + + const newHeaders = new Headers(res.headers); + newHeaders.delete("www-authenticate"); + newHeaders.set("X-Accel-Buffering", "no"); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/app/client/api.ts b/app/client/api.ts index f60b0e2ad71..941919ae73a 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -25,6 +25,7 @@ import { XAIApi } from "./platforms/xai"; import { ChatGLMApi } from "./platforms/glm"; import { SiliconflowApi } from "./platforms/siliconflow"; import { Ai302Api } from "./platforms/ai302"; +import { LiteLLMApi } from "./platforms/litellm"; export const ROLES = ["system", "user", "assistant"] as const; export type MessageRole = (typeof ROLES)[number]; @@ -177,6 +178,9 @@ export class ClientApi { case ModelProvider["302.AI"]: this.llm = new Ai302Api(); break; + case ModelProvider.LiteLLM: + this.llm = new LiteLLMApi(); + break; default: this.llm = new ChatGPTApi(); } @@ -393,6 +397,8 @@ export function getClientApi(provider: ServiceProvider): ClientApi { return new ClientApi(ModelProvider.SiliconFlow); case ServiceProvider["302.AI"]: return new ClientApi(ModelProvider["302.AI"]); + case ServiceProvider.LiteLLM: + return new ClientApi(ModelProvider.LiteLLM); default: return new ClientApi(ModelProvider.GPT); } diff --git a/app/client/platforms/litellm.ts b/app/client/platforms/litellm.ts new file mode 100644 index 00000000000..94384b7290f --- /dev/null +++ b/app/client/platforms/litellm.ts @@ -0,0 +1,284 @@ +"use client"; + +import { + ApiPath, + LITELLM_BASE_URL, + LiteLLMConst, + DEFAULT_MODELS, +} from "@/app/constant"; +import { + useAccessStore, + useAppConfig, + useChatStore, + ChatMessageTool, + usePluginStore, +} from "@/app/store"; +import { preProcessImageContent, streamWithThink } from "@/app/utils/chat"; +import { + ChatOptions, + getHeaders, + LLMApi, + LLMModel, + SpeechOptions, +} from "../api"; +import { getClientConfig } from "@/app/config/client"; +import { + getMessageTextContent, + getMessageTextContentWithoutThinking, + isVisionModel, + getTimeoutMSByModel, +} from "@/app/utils"; +import { RequestPayload } from "./openai"; + +import { fetch } from "@/app/utils/stream"; + +export interface LiteLLMListModelResponse { + object: string; + data: Array<{ + id: string; + object: string; + created?: number; + owned_by?: string; + }>; +} + +export class LiteLLMApi implements LLMApi { + private disableListModels = false; + + path(path: string): string { + const accessStore = useAccessStore.getState(); + + let baseUrl = ""; + + if (accessStore.useCustomConfig) { + baseUrl = accessStore.litellmUrl; + } + + if (baseUrl.length === 0) { + const isApp = !!getClientConfig()?.isApp; + const apiPath = ApiPath.LiteLLM; + baseUrl = isApp ? LITELLM_BASE_URL : apiPath; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, baseUrl.length - 1); + } + if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.LiteLLM)) { + baseUrl = "https://" + baseUrl; + } + + console.log("[Proxy Endpoint] ", baseUrl, path); + + return [baseUrl, path].join("/"); + } + + extractMessage(res: any) { + return res.choices?.at(0)?.message?.content ?? ""; + } + + speech(options: SpeechOptions): Promise { + throw new Error("Method not implemented."); + } + + async chat(options: ChatOptions) { + const visionModel = isVisionModel(options.config.model); + const messages: ChatOptions["messages"] = []; + for (const v of options.messages) { + if (v.role === "assistant") { + const content = getMessageTextContentWithoutThinking(v); + messages.push({ role: v.role, content }); + } else { + const content = visionModel + ? await preProcessImageContent(v.content) + : getMessageTextContent(v); + messages.push({ role: v.role, content }); + } + } + + const modelConfig = { + ...useAppConfig.getState().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, + ...{ + model: options.config.model, + providerName: options.config.providerName, + }, + }; + + const requestPayload: RequestPayload = { + messages, + stream: options.config.stream, + model: modelConfig.model, + temperature: modelConfig.temperature, + presence_penalty: modelConfig.presence_penalty, + frequency_penalty: modelConfig.frequency_penalty, + top_p: modelConfig.top_p, + }; + + console.log("[Request] litellm payload: ", requestPayload); + + const shouldStream = !!options.config.stream; + const controller = new AbortController(); + options.onController?.(controller); + + try { + const chatPath = this.path(LiteLLMConst.ChatPath); + const chatPayload = { + method: "POST", + body: JSON.stringify(requestPayload), + signal: controller.signal, + headers: getHeaders(), + }; + + const requestTimeoutId = setTimeout( + () => controller.abort(), + getTimeoutMSByModel(options.config.model), + ); + + if (shouldStream) { + const [tools, funcs] = usePluginStore + .getState() + .getAsTools( + useChatStore.getState().currentSession().mask?.plugin || [], + ); + return streamWithThink( + chatPath, + requestPayload, + getHeaders(), + tools as any, + funcs, + controller, + // parseSSE + (text: string, runTools: ChatMessageTool[]) => { + const json = JSON.parse(text); + const choices = json.choices as Array<{ + delta: { + content: string | null; + tool_calls: ChatMessageTool[]; + reasoning_content: string | null; + }; + }>; + const tool_calls = choices[0]?.delta?.tool_calls; + if (tool_calls?.length > 0) { + const index = tool_calls[0]?.index; + const id = tool_calls[0]?.id; + const args = tool_calls[0]?.function?.arguments; + if (id) { + runTools.push({ + id, + type: tool_calls[0]?.type, + function: { + name: tool_calls[0]?.function?.name as string, + arguments: args, + }, + }); + } else { + // @ts-ignore + runTools[index]["function"]["arguments"] += args; + } + } + const reasoning = choices[0]?.delta?.reasoning_content; + const content = choices[0]?.delta?.content; + + if ( + (!reasoning || reasoning.length === 0) && + (!content || content.length === 0) + ) { + return { + isThinking: false, + content: "", + }; + } + + if (reasoning && reasoning.length > 0) { + return { + isThinking: true, + content: reasoning, + }; + } else if (content && content.length > 0) { + return { + isThinking: false, + content: content, + }; + } + + return { + isThinking: false, + content: "", + }; + }, + // processToolMessage + ( + requestPayload: RequestPayload, + toolCallMessage: any, + toolCallResult: any[], + ) => { + // @ts-ignore + requestPayload?.messages?.splice( + // @ts-ignore + requestPayload?.messages?.length, + 0, + toolCallMessage, + ...toolCallResult, + ); + }, + options, + ); + } else { + const res = await fetch(chatPath, chatPayload); + clearTimeout(requestTimeoutId); + + const resJson = await res.json(); + const message = this.extractMessage(resJson); + options.onFinish(message, res); + } + } catch (e) { + console.log("[Request] failed to make a chat request", e); + options.onError?.(e as Error); + } + } + + async usage() { + return { + used: 0, + total: 0, + }; + } + + async models(): Promise { + if (this.disableListModels) { + return DEFAULT_MODELS.slice(); + } + + const res = await fetch(this.path(LiteLLMConst.ListModelPath), { + method: "GET", + headers: { + ...getHeaders(), + }, + }); + + if (!res.ok) { + return []; + } + + const resJson = (await res.json()) as LiteLLMListModelResponse; + const chatModels = resJson.data; + console.log("[Models]", chatModels); + + if (!chatModels) { + return []; + } + + let seq = 1000; + return chatModels.map((m) => ({ + name: m.id, + available: true, + sorted: seq++, + provider: { + id: "litellm", + providerName: "LiteLLM", + providerType: "litellm", + sorted: 16, + }, + })); + } +} diff --git a/app/config/server.ts b/app/config/server.ts index 14175eadc8c..35a518dd32a 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -88,6 +88,10 @@ declare global { SILICONFLOW_URL?: string; SILICONFLOW_API_KEY?: string; + // litellm only + LITELLM_URL?: string; + LITELLM_API_KEY?: string; + // 302.AI only AI302_URL?: string; AI302_API_KEY?: string; @@ -167,6 +171,7 @@ export const getServerSideConfig = () => { const isXAI = !!process.env.XAI_API_KEY; const isChatGLM = !!process.env.CHATGLM_API_KEY; const isSiliconFlow = !!process.env.SILICONFLOW_API_KEY; + const isLiteLLM = !!process.env.LITELLM_API_KEY; const isAI302 = !!process.env.AI302_API_KEY; // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); @@ -251,6 +256,10 @@ export const getServerSideConfig = () => { siliconFlowUrl: process.env.SILICONFLOW_URL, siliconFlowApiKey: getApiKey(process.env.SILICONFLOW_API_KEY), + isLiteLLM, + litellmUrl: process.env.LITELLM_URL, + litellmApiKey: getApiKey(process.env.LITELLM_API_KEY), + isAI302, ai302Url: process.env.AI302_URL, ai302ApiKey: getApiKey(process.env.AI302_API_KEY), diff --git a/app/constant.ts b/app/constant.ts index db9842d6027..249e00d6b1c 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -38,6 +38,8 @@ export const SILICONFLOW_BASE_URL = "https://api.siliconflow.cn"; export const AI302_BASE_URL = "https://api.302.ai"; +export const LITELLM_BASE_URL = "http://localhost:4000"; + export const CACHE_URL_PREFIX = "/api/cache"; export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`; @@ -75,6 +77,7 @@ export enum ApiPath { DeepSeek = "/api/deepseek", SiliconFlow = "/api/siliconflow", "302.AI" = "/api/302ai", + LiteLLM = "/api/litellm", } export enum SlotID { @@ -134,6 +137,7 @@ export enum ServiceProvider { DeepSeek = "DeepSeek", SiliconFlow = "SiliconFlow", "302.AI" = "302.AI", + LiteLLM = "LiteLLM", } // Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings @@ -161,6 +165,7 @@ export enum ModelProvider { DeepSeek = "DeepSeek", SiliconFlow = "SiliconFlow", "302.AI" = "302.AI", + LiteLLM = "LiteLLM", } export const Stability = { @@ -278,6 +283,12 @@ export const AI302 = { ListModelPath: "v1/models?llm=1", }; +export const LiteLLMConst = { + ExampleEndpoint: LITELLM_BASE_URL, + ChatPath: "v1/chat/completions", + ListModelPath: "v1/models", +}; + export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang // export const DEFAULT_SYSTEM_TEMPLATE = ` // You are ChatGPT, a large language model trained by {{ServiceProvider}}. diff --git a/app/store/access.ts b/app/store/access.ts index fd55fbdd3d1..3293b2868d9 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -18,6 +18,7 @@ import { CHATGLM_BASE_URL, SILICONFLOW_BASE_URL, AI302_BASE_URL, + LITELLM_BASE_URL, } from "../constant"; import { getHeaders } from "../client/api"; import { getClientConfig } from "../config/client"; @@ -62,6 +63,8 @@ const DEFAULT_SILICONFLOW_URL = isApp const DEFAULT_AI302_URL = isApp ? AI302_BASE_URL : ApiPath["302.AI"]; +const DEFAULT_LITELLM_URL = isApp ? LITELLM_BASE_URL : ApiPath.LiteLLM; + const DEFAULT_ACCESS_STATE = { accessCode: "", useCustomConfig: false, @@ -139,6 +142,10 @@ const DEFAULT_ACCESS_STATE = { ai302Url: DEFAULT_AI302_URL, ai302ApiKey: "", + // litellm + litellmUrl: DEFAULT_LITELLM_URL, + litellmApiKey: "", + // server config needCode: true, hideUserApiKey: false, @@ -226,6 +233,10 @@ export const useAccessStore = createPersistStore( return ensure(get(), ["siliconflowApiKey"]); }, + isValidLiteLLM() { + return ensure(get(), ["litellmApiKey"]); + }, + isAuthorized() { this.fetch(); @@ -245,6 +256,7 @@ export const useAccessStore = createPersistStore( this.isValidXAI() || this.isValidChatGLM() || this.isValidSiliconFlow() || + this.isValidLiteLLM() || !this.enabledAccessControl() || (this.enabledAccessControl() && ensure(get(), ["accessCode"])) ); diff --git a/test/litellm.test.ts b/test/litellm.test.ts new file mode 100644 index 00000000000..e5672279332 --- /dev/null +++ b/test/litellm.test.ts @@ -0,0 +1,271 @@ +/** + * Tests for the LiteLLM provider platform. + * + * Covers: path resolution, message extraction, model listing, + * error responses, and model string format consistency. + */ + +// Mock the stores and config before importing the module +jest.mock("@/app/store", () => ({ + useAccessStore: { + getState: () => ({ + useCustomConfig: true, + litellmUrl: "http://localhost:4000", + litellmApiKey: "sk-test-key", + }), + }, + useAppConfig: { + getState: () => ({ + modelConfig: { + model: "anthropic/claude-sonnet-4-6", + temperature: 0.7, + top_p: 1, + presence_penalty: 0, + frequency_penalty: 0, + }, + }), + }, + useChatStore: { + getState: () => ({ + currentSession: () => ({ + mask: { + modelConfig: {}, + plugin: [], + }, + }), + }), + }, + ChatMessageTool: {}, + usePluginStore: { + getState: () => ({ + getAsTools: () => [[], []], + }), + }, +})); + +jest.mock("@/app/config/client", () => ({ + getClientConfig: () => ({ + isApp: false, + buildMode: "standalone", + }), +})); + +jest.mock("@/app/client/api", () => ({ + getHeaders: () => ({ + "Content-Type": "application/json", + Authorization: "Bearer sk-test-key", + }), + LLMApi: class {}, +})); + +jest.mock("@/app/utils/chat", () => ({ + preProcessImageContent: jest.fn((content: any) => content), + streamWithThink: jest.fn(), +})); + +jest.mock("@/app/utils", () => ({ + getMessageTextContent: (msg: any) => + typeof msg.content === "string" ? msg.content : "", + getMessageTextContentWithoutThinking: (msg: any) => + typeof msg.content === "string" ? msg.content : "", + isVisionModel: () => false, + getTimeoutMSByModel: () => 60000, +})); + +jest.mock("@/app/utils/stream", () => ({ + fetch: jest.fn(), +})); + +import { LiteLLMApi } from "@/app/client/platforms/litellm"; +import { fetch as mockFetch } from "@/app/utils/stream"; + +describe("LiteLLMApi", () => { + let api: LiteLLMApi; + + beforeEach(() => { + api = new LiteLLMApi(); + jest.clearAllMocks(); + }); + + describe("path()", () => { + it("builds correct path with custom config URL", () => { + const result = api.path("v1/chat/completions"); + expect(result).toBe("http://localhost:4000/v1/chat/completions"); + }); + + it("strips trailing slash from base URL", () => { + // Override access store to have trailing slash + const store = require("@/app/store"); + const original = store.useAccessStore.getState; + store.useAccessStore.getState = () => ({ + useCustomConfig: true, + litellmUrl: "http://localhost:4000/", + litellmApiKey: "sk-test", + }); + + const result = api.path("v1/models"); + expect(result).toBe("http://localhost:4000/v1/models"); + + store.useAccessStore.getState = original; + }); + }); + + describe("extractMessage()", () => { + it("extracts content from standard response", () => { + const result = api.extractMessage({ + choices: [{ message: { content: "Hello world" } }], + }); + expect(result).toBe("Hello world"); + }); + + it("returns empty string for missing choices", () => { + expect(api.extractMessage({})).toBe(""); + expect(api.extractMessage({ choices: [] })).toBe(""); + }); + + it("returns empty string for null content", () => { + const result = api.extractMessage({ + choices: [{ message: { content: null } }], + }); + expect(result).toBe(""); + }); + }); + + describe("models()", () => { + it("fetches and parses model list from /v1/models", async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [ + { id: "anthropic/claude-sonnet-4-6", object: "model" }, + { id: "openai/gpt-4o", object: "model" }, + { + id: "bedrock/anthropic.claude-sonnet-4-6-v1", + object: "model", + }, + ], + }), + }; + (mockFetch as jest.Mock).mockResolvedValue(mockResponse); + + const models = await api.models(); + + expect(models).toHaveLength(3); + expect(models[0].name).toBe("anthropic/claude-sonnet-4-6"); + expect(models[0].provider.id).toBe("litellm"); + expect(models[0].provider.providerName).toBe("LiteLLM"); + expect(models[1].name).toBe("openai/gpt-4o"); + expect(models[2].name).toBe("bedrock/anthropic.claude-sonnet-4-6-v1"); + }); + + it("returns empty array on HTTP error", async () => { + (mockFetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 401, + }); + + const models = await api.models(); + expect(models).toEqual([]); + }); + + it("returns empty array when data is null", async () => { + (mockFetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ data: null }), + }); + + const models = await api.models(); + expect(models).toEqual([]); + }); + + it("preserves provider/model format in model IDs", async () => { + const litellmModels = [ + "anthropic/claude-sonnet-4-6", + "openai/gpt-4o", + "vertex_ai/gemini-2.5-flash", + "bedrock/anthropic.claude-sonnet-4-6-v1", + "groq/llama-4-scout-17b-16e-instruct", + "mistral/mistral-large-latest", + ]; + + (mockFetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + data: litellmModels.map((id) => ({ id, object: "model" })), + }), + }); + + const models = await api.models(); + for (let i = 0; i < litellmModels.length; i++) { + expect(models[i].name).toBe(litellmModels[i]); + } + }); + }); + + describe("usage()", () => { + it("returns zero usage (not supported by LiteLLM proxy)", async () => { + const usage = await api.usage(); + expect(usage).toEqual({ used: 0, total: 0 }); + }); + }); + + describe("speech()", () => { + it("throws not implemented error", () => { + expect(() => api.speech({} as any)).toThrow("Method not implemented"); + }); + }); + + describe("chat()", () => { + it("sends correct payload for non-streaming", async () => { + const mockResponse = { + json: async () => ({ + choices: [ + { message: { role: "assistant", content: "4" } }, + ], + }), + }; + (mockFetch as jest.Mock).mockResolvedValue(mockResponse); + + const onFinish = jest.fn(); + const onError = jest.fn(); + + await api.chat({ + messages: [{ role: "user", content: "What is 2+2?" }], + config: { + model: "anthropic/claude-sonnet-4-6", + stream: false, + }, + onFinish, + onError, + onUpdate: jest.fn(), + } as any); + + expect(mockFetch).toHaveBeenCalled(); + const [url, opts] = (mockFetch as jest.Mock).mock.calls[0]; + expect(url).toContain("v1/chat/completions"); + + const body = JSON.parse(opts.body); + expect(body.model).toBe("anthropic/claude-sonnet-4-6"); + expect(body.stream).toBe(false); + expect(body.messages).toHaveLength(1); + expect(body.messages[0].role).toBe("user"); + }); + + it("calls onError when fetch fails", async () => { + (mockFetch as jest.Mock).mockRejectedValue( + new Error("Network error"), + ); + + const onError = jest.fn(); + await api.chat({ + messages: [{ role: "user", content: "test" }], + config: { model: "test-model", stream: false }, + onFinish: jest.fn(), + onError, + onUpdate: jest.fn(), + } as any); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); +});