feat: add MiniMax as alternative LLM provider with provider abstraction

Introduce a configurable AI provider system (lib/ai-provider.ts) that
supports Gemini (default), MiniMax, and Siray.ai with automatic
fallback. The AI_PROVIDER env var selects the primary provider, and
on failure the system falls back to a secondary provider automatically.

- MiniMax M2.7 via OpenAI-compatible API (api.minimax.io/v1)
- Provider abstraction replaces hardcoded Gemini + Siray fallback
- 24 unit tests + 3 integration tests (vitest)
This commit is contained in:
octopus 2026-03-20 21:28:24 +08:00
parent 08304b51a4
commit be46d25807
8 changed files with 1738 additions and 123 deletions

View File

@ -253,8 +253,17 @@ BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
FINNHUB_BASE_URL=https://finnhub.io/api/v1
# Inngest AI (Gemini)
# AI Provider (optional, default: "gemini")
# Supported: "gemini", "minimax", "siray"
# AI_PROVIDER=gemini
# Gemini
GEMINI_API_KEY=your_gemini_api_key
# MiniMax (optional, used when AI_PROVIDER=minimax or as fallback)
# Get your key at https://platform.minimaxi.com
# MINIMAX_API_KEY=your_minimax_api_key
# Inngest Signing Key (required for Vercel deployment)
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
INNGEST_SIGNING_KEY=your_inngest_signing_key
@ -281,8 +290,17 @@ BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
FINNHUB_BASE_URL=https://finnhub.io/api/v1
# Inngest AI (Gemini)
# AI Provider (optional, default: "gemini")
# Supported: "gemini", "minimax", "siray"
# AI_PROVIDER=gemini
# Gemini
GEMINI_API_KEY=your_gemini_api_key
# MiniMax (optional, used when AI_PROVIDER=minimax or as fallback)
# Get your key at https://platform.minimaxi.com
# MINIMAX_API_KEY=your_minimax_api_key
# Inngest Signing Key (required for Vercel deployment)
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
INNGEST_SIGNING_KEY=your_inngest_signing_key

View File

@ -0,0 +1,55 @@
import { describe, it, expect, beforeAll } from "vitest";
import { callAIProvider } from "@/lib/ai-provider";
/**
* Integration tests for AI providers.
* These tests call real APIs and require valid API keys in environment.
* Run with: npx vitest run __tests__/ai-provider.integration.test.ts
*/
describe.skipIf(!process.env.MINIMAX_API_KEY)(
"MiniMax integration",
() => {
it("generates a response from MiniMax M2.7", async () => {
const result = await callAIProvider(
"Reply with exactly: MINIMAX_OK",
"minimax"
);
expect(result).toBeTruthy();
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
}, 30_000);
it("handles a longer prompt", async () => {
const result = await callAIProvider(
"Summarize in one sentence: The stock market is a platform for buying and selling shares of publicly traded companies.",
"minimax"
);
expect(result).toBeTruthy();
expect(result.length).toBeGreaterThan(10);
}, 30_000);
it("generates HTML content for email personalization", async () => {
const result = await callAIProvider(
'Generate a single HTML <p> tag with a brief personalized welcome message for a tech investor. Return only the HTML, no markdown.',
"minimax"
);
expect(result).toBeTruthy();
expect(result.length).toBeGreaterThan(0);
}, 30_000);
}
);
describe.skipIf(!process.env.GEMINI_API_KEY)(
"Gemini integration",
() => {
it("generates a response from Gemini", async () => {
const result = await callAIProvider(
"Reply with exactly: GEMINI_OK",
"gemini"
);
expect(result).toBeTruthy();
expect(typeof result).toBe("string");
}, 30_000);
}
);

View File

@ -0,0 +1,368 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
getProviderConfig,
getFallbackProviderName,
callAIProvider,
callAIProviderWithFallback,
type AIProviderName,
} from "@/lib/ai-provider";
// ── getProviderConfig ──────────────────────────────────────────────
describe("getProviderConfig", () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it("defaults to gemini when no env var is set", () => {
delete process.env.AI_PROVIDER;
const config = getProviderConfig();
expect(config.name).toBe("gemini");
expect(config.baseUrl).toContain("generativelanguage.googleapis.com");
expect(config.model).toBe("gemini-2.5-flash-lite");
});
it("returns minimax config when provider is minimax", () => {
process.env.MINIMAX_API_KEY = "test-key";
const config = getProviderConfig("minimax");
expect(config.name).toBe("minimax");
expect(config.baseUrl).toBe("https://api.minimax.io/v1");
expect(config.model).toBe("MiniMax-M2.7");
expect(config.apiKey).toBe("test-key");
});
it("respects MINIMAX_MODEL env var", () => {
process.env.MINIMAX_MODEL = "MiniMax-M2.5-highspeed";
const config = getProviderConfig("minimax");
expect(config.model).toBe("MiniMax-M2.5-highspeed");
});
it("respects MINIMAX_BASE_URL env var", () => {
process.env.MINIMAX_BASE_URL = "https://custom.minimax.example/v1";
const config = getProviderConfig("minimax");
expect(config.baseUrl).toBe("https://custom.minimax.example/v1");
});
it("returns siray config when provider is siray", () => {
process.env.SIRAY_API_KEY = "siray-key";
const config = getProviderConfig("siray");
expect(config.name).toBe("siray");
expect(config.baseUrl).toBe("https://api.siray.ai/v1");
expect(config.model).toBe("siray-1.0-ultra");
expect(config.apiKey).toBe("siray-key");
});
it("reads AI_PROVIDER from env when no argument is given", () => {
process.env.AI_PROVIDER = "minimax";
process.env.MINIMAX_API_KEY = "k";
const config = getProviderConfig();
expect(config.name).toBe("minimax");
});
it("respects GEMINI_MODEL env var", () => {
process.env.GEMINI_MODEL = "gemini-2.0-flash";
const config = getProviderConfig("gemini");
expect(config.model).toBe("gemini-2.0-flash");
});
});
// ── getFallbackProviderName ────────────────────────────────────────
describe("getFallbackProviderName", () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it("returns minimax when primary is gemini and MINIMAX_API_KEY is set", () => {
process.env.MINIMAX_API_KEY = "k";
expect(getFallbackProviderName("gemini")).toBe("minimax");
});
it("returns siray when primary is gemini and only SIRAY_API_KEY is set", () => {
delete process.env.MINIMAX_API_KEY;
process.env.SIRAY_API_KEY = "s";
expect(getFallbackProviderName("gemini")).toBe("siray");
});
it("returns gemini when primary is minimax", () => {
expect(getFallbackProviderName("minimax")).toBe("gemini");
});
it("returns gemini when primary is siray", () => {
expect(getFallbackProviderName("siray")).toBe("gemini");
});
});
// ── callAIProvider ─────────────────────────────────────────────────
describe("callAIProvider", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("throws when GEMINI_API_KEY is missing for gemini provider", async () => {
delete process.env.GEMINI_API_KEY;
await expect(callAIProvider("hello", "gemini")).rejects.toThrow(
"GEMINI_API_KEY is not set"
);
});
it("throws when MINIMAX_API_KEY is missing for minimax provider", async () => {
delete process.env.MINIMAX_API_KEY;
await expect(callAIProvider("hello", "minimax")).rejects.toThrow(
"MINIMAX_API_KEY is not set"
);
});
it("throws when SIRAY_API_KEY is missing for siray provider", async () => {
delete process.env.SIRAY_API_KEY;
await expect(callAIProvider("hello", "siray")).rejects.toThrow(
"SIRAY_API_KEY is not set"
);
});
it("calls Gemini API with correct format", async () => {
process.env.GEMINI_API_KEY = "test-gemini-key";
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [
{ content: { parts: [{ text: "Hello from Gemini" }] } },
],
}),
});
vi.stubGlobal("fetch", mockFetch);
const result = await callAIProvider("test prompt", "gemini");
expect(result).toBe("Hello from Gemini");
const [url, options] = mockFetch.mock.calls[0];
expect(url).toContain("generativelanguage.googleapis.com");
expect(url).toContain("key=test-gemini-key");
const body = JSON.parse(options.body);
expect(body.contents[0].parts[0].text).toBe("test prompt");
});
it("calls MiniMax API with OpenAI-compatible format", async () => {
process.env.MINIMAX_API_KEY = "test-minimax-key";
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "Hello from MiniMax" } }],
}),
});
vi.stubGlobal("fetch", mockFetch);
const result = await callAIProvider("test prompt", "minimax");
expect(result).toBe("Hello from MiniMax");
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe("https://api.minimax.io/v1/chat/completions");
expect(options.headers["Authorization"]).toBe(
"Bearer test-minimax-key"
);
const body = JSON.parse(options.body);
expect(body.model).toBe("MiniMax-M2.7");
expect(body.messages[0].content).toBe("test prompt");
expect(body.temperature).toBe(0.7);
});
it("calls Siray API with OpenAI-compatible format", async () => {
process.env.SIRAY_API_KEY = "test-siray-key";
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "Hello from Siray" } }],
}),
});
vi.stubGlobal("fetch", mockFetch);
const result = await callAIProvider("test prompt", "siray");
expect(result).toBe("Hello from Siray");
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe("https://api.siray.ai/v1/chat/completions");
expect(options.headers["Authorization"]).toBe("Bearer test-siray-key");
});
it("throws on API error response", async () => {
process.env.GEMINI_API_KEY = "k";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 429,
statusText: "Too Many Requests",
})
);
await expect(callAIProvider("hello", "gemini")).rejects.toThrow(
"Gemini API error: 429"
);
});
it("throws on empty Gemini response", async () => {
process.env.GEMINI_API_KEY = "k";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ candidates: [] }),
})
);
await expect(callAIProvider("hello", "gemini")).rejects.toThrow(
"Gemini returned empty response"
);
});
it("throws on empty MiniMax response", async () => {
process.env.MINIMAX_API_KEY = "k";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ choices: [{ message: {} }] }),
})
);
await expect(callAIProvider("hello", "minimax")).rejects.toThrow(
"minimax returned empty response"
);
});
});
// ── callAIProviderWithFallback ─────────────────────────────────────
describe("callAIProviderWithFallback", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("returns primary provider result on success", async () => {
process.env.AI_PROVIDER = "minimax";
process.env.MINIMAX_API_KEY = "k";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "MiniMax response" } }],
}),
})
);
const result = await callAIProviderWithFallback("test");
expect(result).toBe("MiniMax response");
});
it("falls back to secondary provider on primary failure", async () => {
process.env.AI_PROVIDER = "minimax";
process.env.MINIMAX_API_KEY = "k";
process.env.GEMINI_API_KEY = "g";
let callCount = 0;
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((url: string) => {
callCount++;
if (url.includes("minimax")) {
return Promise.resolve({ ok: false, status: 500, statusText: "Error" });
}
// Gemini fallback
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
candidates: [
{ content: { parts: [{ text: "Gemini fallback" }] } },
],
}),
});
})
);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await callAIProviderWithFallback("test");
expect(result).toBe("Gemini fallback");
expect(callCount).toBe(2);
consoleSpy.mockRestore();
});
it("uses gemini as default primary and minimax as fallback", async () => {
delete process.env.AI_PROVIDER;
process.env.GEMINI_API_KEY = "g";
process.env.MINIMAX_API_KEY = "m";
let callCount = 0;
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((url: string) => {
callCount++;
if (url.includes("googleapis")) {
return Promise.resolve({ ok: false, status: 500, statusText: "Error" });
}
// MiniMax fallback
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "MiniMax fallback" } }],
}),
});
})
);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await callAIProviderWithFallback("test");
expect(result).toBe("MiniMax fallback");
expect(callCount).toBe(2);
consoleSpy.mockRestore();
});
it("throws when both primary and fallback fail", async () => {
process.env.AI_PROVIDER = "minimax";
process.env.MINIMAX_API_KEY = "k";
process.env.GEMINI_API_KEY = "g";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
})
);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(callAIProviderWithFallback("test")).rejects.toThrow();
consoleSpy.mockRestore();
});
});

184
lib/ai-provider.ts Normal file
View File

@ -0,0 +1,184 @@
/**
* AI Provider abstraction for OpenStock.
*
* Supports multiple LLM backends via the AI_PROVIDER environment variable:
* - "gemini" (default) Google Gemini REST API
* - "minimax" MiniMax (OpenAI-compatible)
* - "siray" Siray.ai (OpenAI-compatible)
*
* Each provider returns a plain-text string from the model.
*/
export type AIProviderName = "gemini" | "minimax" | "siray";
export interface AIProviderConfig {
name: AIProviderName;
apiKey: string;
baseUrl: string;
model: string;
}
/**
* Resolve the provider configuration from environment variables.
*/
export function getProviderConfig(
provider?: AIProviderName
): AIProviderConfig {
const name =
provider ||
(process.env.AI_PROVIDER as AIProviderName) ||
"gemini";
switch (name) {
case "minimax":
return {
name: "minimax",
apiKey: process.env.MINIMAX_API_KEY || "",
baseUrl:
process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1",
model: process.env.MINIMAX_MODEL || "MiniMax-M2.7",
};
case "siray":
return {
name: "siray",
apiKey: process.env.SIRAY_API_KEY || "",
baseUrl: "https://api.siray.ai/v1",
model: "siray-1.0-ultra",
};
case "gemini":
default:
return {
name: "gemini",
apiKey: process.env.GEMINI_API_KEY || "",
baseUrl:
"https://generativelanguage.googleapis.com/v1beta/models",
model: process.env.GEMINI_MODEL || "gemini-2.5-flash-lite",
};
}
}
/**
* Get the fallback provider: if the primary is Gemini use MiniMax,
* otherwise fall back to Gemini.
*/
export function getFallbackProviderName(
primary: AIProviderName
): AIProviderName {
if (primary === "gemini") {
// Prefer MiniMax as fallback when a key is available, else Siray
if (process.env.MINIMAX_API_KEY) return "minimax";
if (process.env.SIRAY_API_KEY) return "siray";
return "minimax"; // caller will see missing-key error
}
return "gemini";
}
// ── Provider call implementations ──────────────────────────────────
async function callGemini(
prompt: string,
config: AIProviderConfig
): Promise<string> {
if (!config.apiKey) throw new Error("GEMINI_API_KEY is not set");
const url = `${config.baseUrl}/${config.model}:generateContent?key=${config.apiKey}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
}),
});
if (!res.ok) {
throw new Error(`Gemini API error: ${res.status} ${res.statusText}`);
}
const data = await res.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error("Gemini returned empty response");
return text;
}
async function callOpenAICompatible(
prompt: string,
config: AIProviderConfig
): Promise<string> {
if (!config.apiKey) {
throw new Error(
`${config.name.toUpperCase()}_API_KEY is not set`
);
}
const url = `${config.baseUrl}/chat/completions`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
}),
});
if (!res.ok) {
throw new Error(
`${config.name} API error: ${res.status} ${res.statusText}`
);
}
const data = await res.json();
const text = data?.choices?.[0]?.message?.content;
if (!text) {
throw new Error(`${config.name} returned empty response`);
}
return text;
}
// ── Public API ─────────────────────────────────────────────────────
/**
* Call the configured (or specified) AI provider and return the model
* response as a plain string.
*/
export async function callAIProvider(
prompt: string,
provider?: AIProviderName
): Promise<string> {
const config = getProviderConfig(provider);
if (config.name === "gemini") {
return callGemini(prompt, config);
}
// MiniMax and Siray both use OpenAI-compatible endpoints
return callOpenAICompatible(prompt, config);
}
/**
* Call the AI provider with automatic fallback.
* Tries the primary provider first; on failure switches to the fallback.
*/
export async function callAIProviderWithFallback(
prompt: string
): Promise<string> {
const primaryName =
(process.env.AI_PROVIDER as AIProviderName) || "gemini";
const fallbackName = getFallbackProviderName(primaryName);
try {
return await callAIProvider(prompt, primaryName);
} catch (primaryError) {
console.error(
`⚠️ ${primaryName} failed, switching to ${fallbackName} fallback`,
primaryError
);
return await callAIProvider(prompt, fallbackName);
}
}

View File

@ -5,6 +5,7 @@ import { getAllUsersForNewsEmail } from "@/lib/actions/user.actions";
import { getWatchlistSymbolsByEmail } from "@/lib/actions/watchlist.actions";
import { getNews } from "@/lib/actions/finnhub.actions";
import { getFormattedTodayDate } from "@/lib/utils";
import { callAIProviderWithFallback } from "@/lib/ai-provider";
export const sendSignUpEmail = inngest.createFunction(
{ id: 'sign-up-email' },
@ -20,60 +21,20 @@ export const sendSignUpEmail = inngest.createFunction(
const prompt = PERSONALIZED_WELCOME_EMAIL_PROMPT.replace('{{userProfile}}', userProfile)
let aiResponse;
try {
aiResponse = await step.ai.infer('generate-welcome-intro', {
model: step.ai.models.gemini({ model: 'gemini-2.5-flash-lite' }),
body: {
contents: [
{
role: 'user',
parts: [
{ text: prompt }
]
}]
}
});
} catch (error) {
console.error("⚠️ Gemini API failed, switching to Siray.ai fallback", error);
// Fallback Step
aiResponse = await step.run('generate-welcome-intro-fallback', async () => {
const SIRAY_API_KEY = process.env.SIRAY_API_KEY;
if (!SIRAY_API_KEY) throw new Error("Siray API Key missing");
// Simulated OpenAI-compatible call
const res = await fetch('https://api.siray.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${SIRAY_API_KEY}`
},
body: JSON.stringify({
model: 'siray-1.0-ultra', // Hypothetical model
messages: [{ role: 'user', content: prompt }]
})
});
if (!res.ok) throw new Error(`Siray API Error: ${res.statusText}`);
const data = await res.json();
// Map to Gemini format for compatibility downstream
return {
candidates: [{
content: { parts: [{ text: data.choices[0].message.content }] }
}]
};
});
}
const introText = await step.run('generate-welcome-intro', async () => {
try {
return await callAIProviderWithFallback(prompt);
} catch (error) {
console.error("⚠️ All AI providers failed for welcome email", error);
return 'Thanks for joining Openstock. You now have the tools to track markets and make smarter moves.';
}
});
await step.run('send-welcome-email', async () => {
try {
const part = aiResponse.candidates?.[0]?.content?.parts?.[0];
const introText = (part && 'text' in part ? part.text : null) || 'Thanks for joining Openstock. You now have the tools to track markets and make smarter moves.'
const { data: { email, name } } = event;
// introText is already a plain string from the AI provider
console.log(`📧 Attempting to send welcome email to: ${email}`);
const result = await sendWelcomeEmail({ email, name, intro: introText });
@ -115,43 +76,14 @@ export const sendWeeklyNewsSummary = inngest.createFunction(
.replace('Daily', 'Weekly');
let aiResponse;
try {
aiResponse = await step.ai.infer('generate-news-summary', {
model: step.ai.models.gemini({ model: 'gemini-2.5-flash-lite' }),
body: { contents: [{ role: 'user', parts: [{ text: prompt }] }] }
});
} catch (error) {
console.error("⚠️ Gemini API failed (Weekly News), switching to Siray.ai fallback", error);
aiResponse = await step.run('generate-news-summary-fallback', async () => {
const SIRAY_API_KEY = process.env.SIRAY_API_KEY;
if (!SIRAY_API_KEY) return { candidates: [{ content: { parts: [{ text: "Market is moving. Log in to see more." }] } }] };
const res = await fetch('https://api.siray.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${SIRAY_API_KEY}`
},
body: JSON.stringify({
model: 'siray-1.0-ultra',
messages: [{ role: 'user', content: prompt }]
})
});
if (!res.ok) throw new Error("Siray API Error");
const data = await res.json();
return {
candidates: [{
content: { parts: [{ text: data.choices[0].message.content }] }
}]
};
});
}
const part = aiResponse.candidates?.[0]?.content?.parts?.[0];
const summaryText = (part && 'text' in part ? part.text : null) || 'Market is moving. Log in to see more.';
const summaryText = await step.run('generate-news-summary', async () => {
try {
return await callAIProviderWithFallback(prompt);
} catch (error) {
console.error("⚠️ All AI providers failed for news summary", error);
return 'Market is moving. Log in to see more.';
}
});
// Step 3: Send Broadcast via Kit
await step.run('send-kit-broadcast', async () => {

1107
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,9 @@
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"test:db": "node scripts/test-db.mjs"
"test:db": "node scripts/test-db.mjs",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
@ -52,6 +54,7 @@
"eslint-config-next": "15.5.4",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.0"
}
}

14
vitest.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
globals: true,
environment: "node",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
});