From 7bc194e068a6f44ba55472fe81df40e657ac352b Mon Sep 17 00:00:00 2001 From: PR Bot Date: Mon, 23 Mar 2026 22:56:53 +0800 Subject: [PATCH] feat: add MiniMax as first-class LLM provider Add MiniMax AI (https://www.minimax.io/) as a configurable LLM provider alongside Anthropic. MiniMax provides an OpenAI-compatible API with models like MiniMax-M2.7 and MiniMax-M2.5-highspeed. Changes: - Add /api/user/config/minimax GET/POST endpoints for config management - Add MiniMax tab to Settings dialog with API key, base URL, and model - Update loadEnvVarsForSandbox to inject MINIMAX_API_KEY, MINIMAX_BASE_URL, and MINIMAX_MODEL environment variables into sandboxes - Add MiniMax to .env.template with default base URL - Add vitest config and unit/integration tests for MiniMax provider - Update README acknowledgments --- .env.template | 4 + README.md | 3 +- .../api/user/config/minimax/route.test.ts | 210 ++++++++++++++++++ .../integration/minimax-provider.test.ts | 208 +++++++++++++++++ __tests__/services/aiproxy.test.ts | 133 +++++++++++ app/api/user/config/minimax/route.ts | 187 ++++++++++++++++ components/dialog/settings-dialog.tsx | 181 ++++++++++++++- lib/services/aiproxy.ts | 30 ++- vitest.config.ts | 15 ++ 9 files changed, 965 insertions(+), 6 deletions(-) create mode 100644 __tests__/api/user/config/minimax/route.test.ts create mode 100644 __tests__/integration/minimax-provider.test.ts create mode 100644 __tests__/services/aiproxy.test.ts create mode 100644 app/api/user/config/minimax/route.ts create mode 100644 vitest.config.ts diff --git a/.env.template b/.env.template index 1f58795..0859208 100644 --- a/.env.template +++ b/.env.template @@ -34,6 +34,10 @@ RUNTIME_IMAGE="" AIPROXY_ENDPOINT="" ANTHROPIC_BASE_URL="" +# MiniMax (OpenAI-compatible API) +MINIMAX_BASE_URL="https://api.minimax.io/v1" +MINIMAX_MODEL="" + # Log LOG_LEVEL="info" diff --git a/README.md b/README.md index f6abd2e..55bd3f5 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release history. ## Acknowledgments - [Anthropic](https://www.anthropic.com/) for Claude Code +- [MiniMax](https://www.minimax.io/) for MiniMax AI models (M2.7, M2.5-highspeed) - [Sealos](https://sealos.io/) for Kubernetes platform - [ttyd](https://github.com/tsl0922/ttyd) for web terminal - [FileBrowser](https://github.com/filebrowser/filebrowser) for file management @@ -225,5 +226,5 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
100% AI-generated code. Prompted by [@fanux](https://github.com/fanux). -
Powered by Claude Code, with models from Anthropic (Sonnet, Opus), Google (Gemini), Zhipu AI (GLM), and Moonshot (Kimi). +
Powered by Claude Code, with models from Anthropic (Sonnet, Opus), Google (Gemini), MiniMax (M2.7), Zhipu AI (GLM), and Moonshot (Kimi).
diff --git a/__tests__/api/user/config/minimax/route.test.ts b/__tests__/api/user/config/minimax/route.test.ts new file mode 100644 index 0000000..c05135b --- /dev/null +++ b/__tests__/api/user/config/minimax/route.test.ts @@ -0,0 +1,210 @@ +/** + * Unit tests for MiniMax Configuration API + * + * Tests the GET and POST endpoints at /api/user/config/minimax + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest' + +// Mock dependencies +vi.mock('@/lib/db', () => ({ + prisma: { + userConfig: { + findMany: vi.fn(), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + $transaction: vi.fn((fn: (tx: unknown) => Promise) => + fn({ + userConfig: { + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + }) + ), + }, +})) + +vi.mock('@/lib/auth', () => ({ + auth: vi.fn(), +})) + +vi.mock('@/lib/logger', () => ({ + logger: { + child: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})) + +// Mock withAuth to pass through session +vi.mock('@/lib/api-auth', () => ({ + withAuth: (handler: Function) => { + return async (req: Request) => { + const session = { user: { id: 'test-user-id' } } + return handler(req, { params: Promise.resolve({}) }, session) + } + }, +})) + +import { prisma } from '@/lib/db' + +describe('GET /api/user/config/minimax', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return MiniMax configuration when configs exist', async () => { + const mockConfigs = [ + { key: 'MINIMAX_API_KEY', value: 'test-api-key' }, + { key: 'MINIMAX_API', value: 'https://api.minimax.io/v1' }, + { key: 'MINIMAX_MODEL', value: 'MiniMax-M2.7' }, + ] + vi.mocked(prisma.userConfig.findMany).mockResolvedValue(mockConfigs as never) + + const { GET } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax') + const response = await GET(req as never, { params: Promise.resolve({}) }) + const data = await response.json() + + expect(data.apiKey).toBe('test-api-key') + expect(data.apiBaseUrl).toBe('https://api.minimax.io/v1') + expect(data.model).toBe('MiniMax-M2.7') + }) + + it('should return nulls when no configs exist', async () => { + vi.mocked(prisma.userConfig.findMany).mockResolvedValue([] as never) + + const { GET } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax') + const response = await GET(req as never, { params: Promise.resolve({}) }) + const data = await response.json() + + expect(data.apiKey).toBeNull() + expect(data.apiBaseUrl).toBeNull() + expect(data.model).toBeNull() + }) + + it('should return 500 on database error', async () => { + vi.mocked(prisma.userConfig.findMany).mockRejectedValue(new Error('DB error') as never) + + const { GET } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax') + const response = await GET(req as never, { params: Promise.resolve({}) }) + + expect(response.status).toBe(500) + const data = await response.json() + expect(data.error).toBe('Failed to fetch MiniMax configuration') + }) +}) + +describe('POST /api/user/config/minimax', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should save MiniMax configuration successfully', async () => { + const { POST } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax', { + method: 'POST', + body: JSON.stringify({ + apiBaseUrl: 'https://api.minimax.io/v1', + apiKey: 'test-api-key', + model: 'MiniMax-M2.7', + }), + headers: { 'Content-Type': 'application/json' }, + }) + const response = await POST(req as never, { params: Promise.resolve({}) }) + const data = await response.json() + + expect(data.success).toBe(true) + expect(data.message).toBe('MiniMax configuration saved successfully') + }) + + it('should reject missing API base URL', async () => { + const { POST } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax', { + method: 'POST', + body: JSON.stringify({ + apiBaseUrl: '', + apiKey: 'test-api-key', + }), + headers: { 'Content-Type': 'application/json' }, + }) + const response = await POST(req as never, { params: Promise.resolve({}) }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('API base URL is required') + }) + + it('should reject missing API key', async () => { + const { POST } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax', { + method: 'POST', + body: JSON.stringify({ + apiBaseUrl: 'https://api.minimax.io/v1', + apiKey: '', + }), + headers: { 'Content-Type': 'application/json' }, + }) + const response = await POST(req as never, { params: Promise.resolve({}) }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('API key is required') + }) + + it('should reject invalid URL format', async () => { + const { POST } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax', { + method: 'POST', + body: JSON.stringify({ + apiBaseUrl: 'not-a-url', + apiKey: 'test-api-key', + }), + headers: { 'Content-Type': 'application/json' }, + }) + const response = await POST(req as never, { params: Promise.resolve({}) }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Invalid API base URL format') + }) + + it('should handle optional model being empty (delete config)', async () => { + const { POST } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax', { + method: 'POST', + body: JSON.stringify({ + apiBaseUrl: 'https://api.minimax.io/v1', + apiKey: 'test-api-key', + model: '', + }), + headers: { 'Content-Type': 'application/json' }, + }) + const response = await POST(req as never, { params: Promise.resolve({}) }) + const data = await response.json() + + expect(data.success).toBe(true) + }) + + it('should save without optional model field', async () => { + const { POST } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax', { + method: 'POST', + body: JSON.stringify({ + apiBaseUrl: 'https://api.minimax.io/v1', + apiKey: 'test-api-key', + }), + headers: { 'Content-Type': 'application/json' }, + }) + const response = await POST(req as never, { params: Promise.resolve({}) }) + const data = await response.json() + + expect(data.success).toBe(true) + }) +}) diff --git a/__tests__/integration/minimax-provider.test.ts b/__tests__/integration/minimax-provider.test.ts new file mode 100644 index 0000000..9bfcf7c --- /dev/null +++ b/__tests__/integration/minimax-provider.test.ts @@ -0,0 +1,208 @@ +/** + * Integration tests for MiniMax provider + * + * Tests the end-to-end flow of MiniMax configuration: + * - API endpoint saves config to database + * - loadEnvVarsForSandbox reads config and maps to env vars + * - Settings UI interacts with API correctly + * + * These tests require a running database and should be run with: + * DATABASE_URL=... vitest run __tests__/integration/ + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest' + +// Mock prisma with in-memory store +const configStore: Map = + new Map() + +vi.mock('@/lib/db', () => ({ + prisma: { + userConfig: { + findMany: vi.fn(async ({ where }: { where: { userId: string; key: { in: string[] } } }) => { + return Array.from(configStore.values()).filter( + (c) => where.key.in.includes(c.key) + ) + }), + upsert: vi.fn( + async ({ + create, + update, + where, + }: { + create: { key: string; value: string; category: string; isSecret: boolean } + update: { value: string } + where: { userId_key: { key: string } } + }) => { + const existing = configStore.get(where.userId_key.key) + if (existing) { + existing.value = update.value + configStore.set(where.userId_key.key, existing) + } else { + configStore.set(create.key, create) + } + } + ), + deleteMany: vi.fn(async ({ where }: { where: { key: string } }) => { + configStore.delete(where.key) + }), + }, + $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => { + const tx = { + userConfig: { + upsert: vi.fn( + async ({ + create, + update, + where, + }: { + create: { key: string; value: string; category: string; isSecret: boolean } + update: { value: string } + where: { userId_key: { key: string } } + }) => { + const existing = configStore.get(where.userId_key.key) + if (existing) { + existing.value = update.value + configStore.set(where.userId_key.key, existing) + } else { + configStore.set(create.key, create) + } + } + ), + deleteMany: vi.fn(async ({ where }: { where: { key: string } }) => { + configStore.delete(where.key) + }), + }, + } + await fn(tx) + }), + }, +})) + +vi.mock('@/lib/auth', () => ({ + auth: vi.fn(), +})) + +vi.mock('@/lib/env', () => ({ + env: { + AIPROXY_ENDPOINT: undefined, + ANTHROPIC_BASE_URL: undefined, + ANTHROPIC_MODEL: undefined, + ANTHROPIC_SMALL_FAST_MODEL: undefined, + }, +})) + +vi.mock('@/lib/logger', () => ({ + logger: { + child: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + withAuth: (handler: Function) => { + return async (req: Request) => { + const session = { user: { id: 'integration-test-user' } } + return handler(req, { params: Promise.resolve({}) }, session) + } + }, +})) + +describe('MiniMax Provider Integration', () => { + beforeEach(() => { + configStore.clear() + vi.clearAllMocks() + }) + + it('should save MiniMax config via API and load it for sandbox', async () => { + // Step 1: Save config via POST endpoint + const { POST } = await import('@/app/api/user/config/minimax/route') + const saveReq = new Request('http://localhost/api/user/config/minimax', { + method: 'POST', + body: JSON.stringify({ + apiBaseUrl: 'https://api.minimax.io/v1', + apiKey: 'integration-test-key', + model: 'MiniMax-M2.7', + }), + headers: { 'Content-Type': 'application/json' }, + }) + const saveResponse = await POST(saveReq as never, { params: Promise.resolve({}) }) + const saveData = await saveResponse.json() + + expect(saveData.success).toBe(true) + + // Step 2: Verify config was stored + expect(configStore.has('MINIMAX_API_KEY')).toBe(true) + expect(configStore.has('MINIMAX_API')).toBe(true) + expect(configStore.has('MINIMAX_MODEL')).toBe(true) + + // Step 3: Load config via GET endpoint + const { GET } = await import('@/app/api/user/config/minimax/route') + const getReq = new Request('http://localhost/api/user/config/minimax') + const getResponse = await GET(getReq as never, { params: Promise.resolve({}) }) + const getData = await getResponse.json() + + expect(getData.apiKey).toBe('integration-test-key') + expect(getData.apiBaseUrl).toBe('https://api.minimax.io/v1') + expect(getData.model).toBe('MiniMax-M2.7') + }) + + it('should coexist with Anthropic config without interference', async () => { + // Set up Anthropic config + configStore.set('ANTHROPIC_API_KEY', { + key: 'ANTHROPIC_API_KEY', + value: 'sk-ant-existing', + category: 'anthropic', + isSecret: true, + }) + configStore.set('ANTHROPIC_API', { + key: 'ANTHROPIC_API', + value: 'https://api.anthropic.com', + category: 'anthropic', + isSecret: false, + }) + + // Save MiniMax config via API + const { POST } = await import('@/app/api/user/config/minimax/route') + const saveReq = new Request('http://localhost/api/user/config/minimax', { + method: 'POST', + body: JSON.stringify({ + apiBaseUrl: 'https://api.minimax.io/v1', + apiKey: 'minimax-key', + }), + headers: { 'Content-Type': 'application/json' }, + }) + await POST(saveReq as never, { params: Promise.resolve({}) }) + + // Verify Anthropic config is untouched + expect(configStore.get('ANTHROPIC_API_KEY')?.value).toBe('sk-ant-existing') + expect(configStore.get('ANTHROPIC_API')?.value).toBe('https://api.anthropic.com') + + // Verify MiniMax config exists + expect(configStore.get('MINIMAX_API_KEY')?.value).toBe('minimax-key') + }) + + it('should store MiniMax API key as secret', async () => { + const { POST } = await import('@/app/api/user/config/minimax/route') + const req = new Request('http://localhost/api/user/config/minimax', { + method: 'POST', + body: JSON.stringify({ + apiBaseUrl: 'https://api.minimax.io/v1', + apiKey: 'secret-key', + }), + headers: { 'Content-Type': 'application/json' }, + }) + await POST(req as never, { params: Promise.resolve({}) }) + + const apiKeyConfig = configStore.get('MINIMAX_API_KEY') + expect(apiKeyConfig?.isSecret).toBe(true) + expect(apiKeyConfig?.category).toBe('minimax') + + const apiUrlConfig = configStore.get('MINIMAX_API') + expect(apiUrlConfig?.isSecret).toBe(false) + }) +}) diff --git a/__tests__/services/aiproxy.test.ts b/__tests__/services/aiproxy.test.ts new file mode 100644 index 0000000..6100fb0 --- /dev/null +++ b/__tests__/services/aiproxy.test.ts @@ -0,0 +1,133 @@ +/** + * Unit tests for loadEnvVarsForSandbox service + * + * Tests that MiniMax env vars are correctly loaded and mapped + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest' + +// Mock dependencies +vi.mock('@/lib/db', () => ({ + prisma: { + userConfig: { + findMany: vi.fn(), + }, + }, +})) + +vi.mock('@/lib/env', () => ({ + env: { + AIPROXY_ENDPOINT: undefined, + ANTHROPIC_BASE_URL: undefined, + ANTHROPIC_MODEL: undefined, + ANTHROPIC_SMALL_FAST_MODEL: undefined, + }, +})) + +vi.mock('@/lib/logger', () => ({ + logger: { + child: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})) + +import { prisma } from '@/lib/db' + +describe('loadEnvVarsForSandbox', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should load MiniMax env vars alongside Anthropic', async () => { + const mockConfigs = [ + { key: 'ANTHROPIC_API_KEY', value: 'sk-ant-test' }, + { key: 'ANTHROPIC_API', value: 'https://api.anthropic.com' }, + { key: 'ANTHROPIC_MODEL', value: 'claude-sonnet-4-5-20250929' }, + { key: 'ANTHROPIC_SMALL_FAST_MODEL', value: 'claude-3-5-haiku-20241022' }, + { key: 'MINIMAX_API_KEY', value: 'minimax-test-key' }, + { key: 'MINIMAX_API', value: 'https://api.minimax.io/v1' }, + { key: 'MINIMAX_MODEL', value: 'MiniMax-M2.7' }, + ] + vi.mocked(prisma.userConfig.findMany).mockResolvedValue(mockConfigs as never) + + const { loadEnvVarsForSandbox } = await import('@/lib/services/aiproxy') + const envVars = await loadEnvVarsForSandbox('test-user-id') + + // Anthropic vars + expect(envVars.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-test') + expect(envVars.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com') + expect(envVars.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-20250929') + expect(envVars.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-3-5-haiku-20241022') + + // MiniMax vars + expect(envVars.MINIMAX_API_KEY).toBe('minimax-test-key') + expect(envVars.MINIMAX_BASE_URL).toBe('https://api.minimax.io/v1') + expect(envVars.MINIMAX_MODEL).toBe('MiniMax-M2.7') + }) + + it('should return only Anthropic vars when no MiniMax config exists', async () => { + const mockConfigs = [ + { key: 'ANTHROPIC_API_KEY', value: 'sk-ant-test' }, + { key: 'ANTHROPIC_API', value: 'https://api.anthropic.com' }, + ] + vi.mocked(prisma.userConfig.findMany).mockResolvedValue(mockConfigs as never) + + const { loadEnvVarsForSandbox } = await import('@/lib/services/aiproxy') + const envVars = await loadEnvVarsForSandbox('test-user-id') + + expect(envVars.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-test') + expect(envVars.MINIMAX_API_KEY).toBeUndefined() + expect(envVars.MINIMAX_BASE_URL).toBeUndefined() + expect(envVars.MINIMAX_MODEL).toBeUndefined() + }) + + it('should return only MiniMax vars when no Anthropic config exists', async () => { + const mockConfigs = [ + { key: 'MINIMAX_API_KEY', value: 'minimax-test-key' }, + { key: 'MINIMAX_API', value: 'https://api.minimax.io/v1' }, + { key: 'MINIMAX_MODEL', value: 'MiniMax-M2.5-highspeed' }, + ] + vi.mocked(prisma.userConfig.findMany).mockResolvedValue(mockConfigs as never) + + const { loadEnvVarsForSandbox } = await import('@/lib/services/aiproxy') + const envVars = await loadEnvVarsForSandbox('test-user-id') + + expect(envVars.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(envVars.MINIMAX_API_KEY).toBe('minimax-test-key') + expect(envVars.MINIMAX_BASE_URL).toBe('https://api.minimax.io/v1') + expect(envVars.MINIMAX_MODEL).toBe('MiniMax-M2.5-highspeed') + }) + + it('should return empty object when no configs exist', async () => { + vi.mocked(prisma.userConfig.findMany).mockResolvedValue([] as never) + + const { loadEnvVarsForSandbox } = await import('@/lib/services/aiproxy') + const envVars = await loadEnvVarsForSandbox('test-user-id') + + expect(Object.keys(envVars)).toHaveLength(0) + }) + + it('should query database with MiniMax keys included', async () => { + vi.mocked(prisma.userConfig.findMany).mockResolvedValue([] as never) + + const { loadEnvVarsForSandbox } = await import('@/lib/services/aiproxy') + await loadEnvVarsForSandbox('test-user-id') + + expect(prisma.userConfig.findMany).toHaveBeenCalledWith({ + where: { + userId: 'test-user-id', + key: { + in: expect.arrayContaining([ + 'MINIMAX_API_KEY', + 'MINIMAX_API', + 'MINIMAX_MODEL', + ]), + }, + }, + }) + }) +}) diff --git a/app/api/user/config/minimax/route.ts b/app/api/user/config/minimax/route.ts new file mode 100644 index 0000000..3661632 --- /dev/null +++ b/app/api/user/config/minimax/route.ts @@ -0,0 +1,187 @@ +/** + * MiniMax Configuration API + * + * GET /api/user/config/minimax + * - Get MiniMax API configuration + * - Returns: { apiKey: string | null, apiBaseUrl: string | null, model: string | null } + * + * POST /api/user/config/minimax + * - Save MiniMax API configuration + * - Body: { apiBaseUrl: string, apiKey: string, model?: string } + * - Returns: { success: true } + */ + +import { NextRequest, NextResponse } from 'next/server' + +import { type RouteContext, withAuth } from '@/lib/api-auth' +import { prisma } from '@/lib/db' +import { logger as baseLogger } from '@/lib/logger' + +const logger = baseLogger.child({ module: 'api/user/config/minimax' }) + +const MINIMAX_API_KEY = 'MINIMAX_API_KEY' +const MINIMAX_API = 'MINIMAX_API' +const MINIMAX_MODEL = 'MINIMAX_MODEL' + +type GetMiniMaxConfigResponse = + | { error: string } + | { + apiKey: string | null + apiBaseUrl: string | null + model: string | null + } + +/** + * GET /api/user/config/minimax + * Get MiniMax API configuration + */ +export const GET = withAuth( + async (_req: NextRequest, _context: RouteContext, session) => { + try { + const configs = await prisma.userConfig.findMany({ + where: { + userId: session.user.id, + key: { + in: [MINIMAX_API_KEY, MINIMAX_API, MINIMAX_MODEL], + }, + }, + }) + + const apiKey = configs.find((c) => c.key === MINIMAX_API_KEY)?.value || null + const apiBaseUrl = configs.find((c) => c.key === MINIMAX_API)?.value || null + const model = configs.find((c) => c.key === MINIMAX_MODEL)?.value || null + + return NextResponse.json({ + apiKey, + apiBaseUrl, + model, + }) + } catch (error) { + logger.error(`Failed to fetch MiniMax config: ${error}`) + return NextResponse.json( + { error: 'Failed to fetch MiniMax configuration' }, + { status: 500 } + ) + } + } +) + +/** + * POST /api/user/config/minimax + * Save MiniMax API configuration + */ +interface SaveMiniMaxConfigRequest { + apiBaseUrl: string + apiKey: string + model?: string +} + +type PostMiniMaxConfigResponse = { error: string } | { success: true; message: string } + +export const POST = withAuth( + async (req: NextRequest, _context: RouteContext, session) => { + try { + const body: SaveMiniMaxConfigRequest = await req.json() + + // Validate inputs + if (!body.apiBaseUrl || typeof body.apiBaseUrl !== 'string') { + return NextResponse.json({ error: 'API base URL is required' }, { status: 400 }) + } + + if (!body.apiKey || typeof body.apiKey !== 'string') { + return NextResponse.json({ error: 'API key is required' }, { status: 400 }) + } + + // Validate URL format + try { + new URL(body.apiBaseUrl) + } catch { + return NextResponse.json({ error: 'Invalid API base URL format' }, { status: 400 }) + } + + // Execute all operations in a transaction + await prisma.$transaction(async (tx) => { + // Save API key + await tx.userConfig.upsert({ + where: { + userId_key: { + userId: session.user.id, + key: MINIMAX_API_KEY, + }, + }, + create: { + userId: session.user.id, + key: MINIMAX_API_KEY, + value: body.apiKey, + category: 'minimax', + isSecret: true, + }, + update: { + value: body.apiKey, + }, + }) + + // Save API base URL + await tx.userConfig.upsert({ + where: { + userId_key: { + userId: session.user.id, + key: MINIMAX_API, + }, + }, + create: { + userId: session.user.id, + key: MINIMAX_API, + value: body.apiBaseUrl, + category: 'minimax', + isSecret: false, + }, + update: { + value: body.apiBaseUrl, + }, + }) + + // Save or clear model if provided + if (body.model !== undefined) { + if (body.model === '' || body.model === null) { + await tx.userConfig.deleteMany({ + where: { + userId: session.user.id, + key: MINIMAX_MODEL, + }, + }) + } else { + await tx.userConfig.upsert({ + where: { + userId_key: { + userId: session.user.id, + key: MINIMAX_MODEL, + }, + }, + create: { + userId: session.user.id, + key: MINIMAX_MODEL, + value: body.model, + category: 'minimax', + isSecret: false, + }, + update: { + value: body.model, + }, + }) + } + } + }) + + logger.info(`MiniMax configuration saved for user ${session.user.id}`) + + return NextResponse.json({ + success: true, + message: 'MiniMax configuration saved successfully', + }) + } catch (error) { + logger.error(`Failed to save MiniMax config: ${error}`) + return NextResponse.json({ error: 'Failed to save MiniMax configuration' }, { status: 500 }) + } + } +) diff --git a/components/dialog/settings-dialog.tsx b/components/dialog/settings-dialog.tsx index 3416925..b82e6f4 100644 --- a/components/dialog/settings-dialog.tsx +++ b/components/dialog/settings-dialog.tsx @@ -30,7 +30,7 @@ import { useSealos } from '@/provider/sealos'; interface SettingsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - defaultTab?: 'system-prompt' | 'kubeconfig' | 'anthropic' | 'github'; + defaultTab?: 'system-prompt' | 'kubeconfig' | 'anthropic' | 'minimax' | 'github'; } const DEFAULT_SYSTEM_PROMPT = `You are an AI full-stack developer working in a Next.js environment. @@ -61,7 +61,7 @@ const DEFAULT_SYSTEM_PROMPT = `You are an AI full-stack developer working in a N - Optimize for performance and SEO - Follow modern React patterns and best practices`; -type TabType = 'system-prompt' | 'kubeconfig' | 'anthropic' | 'github'; +type TabType = 'system-prompt' | 'kubeconfig' | 'anthropic' | 'minimax' | 'github'; export default function SettingsDialog({ open, @@ -90,6 +90,13 @@ export default function SettingsDialog({ const [isAnthropicLoading, setIsAnthropicLoading] = useState(false); const [isAnthropicInitialLoading, setIsAnthropicInitialLoading] = useState(true); + // MiniMax state + const [minimaxApiKey, setMinimaxApiKey] = useState(''); + const [minimaxApiBaseUrl, setMinimaxApiBaseUrl] = useState(''); + const [minimaxModel, setMinimaxModel] = useState(''); + const [isMinimaxLoading, setIsMinimaxLoading] = useState(false); + const [isMinimaxInitialLoading, setIsMinimaxInitialLoading] = useState(true); + // GitHub state const [githubInstallation, setGithubInstallation] = useState(null); const [isGithubLoading, setIsGithubLoading] = useState(false); @@ -99,6 +106,7 @@ export default function SettingsDialog({ const [showSystemPromptConfirm, setShowSystemPromptConfirm] = useState(false); const [showSystemPromptResetConfirm, setShowSystemPromptResetConfirm] = useState(false); const [showAnthropicConfirm, setShowAnthropicConfirm] = useState(false); + const [showMinimaxConfirm, setShowMinimaxConfirm] = useState(false); // Load data when dialog opens useEffect(() => { @@ -108,6 +116,7 @@ export default function SettingsDialog({ loadKubeconfig(); } loadAnthropicConfig(); + loadMinimaxConfig(); loadGithubStatus(); } }, [open, isSealos]); @@ -170,6 +179,23 @@ export default function SettingsDialog({ } }; + const loadMinimaxConfig = async () => { + try { + const data = await fetchClient.GET<{ + apiKey: string | null; + apiBaseUrl: string | null; + model: string | null; + }>('/api/user/config/minimax'); + setMinimaxApiKey(data.apiKey || ''); + setMinimaxApiBaseUrl(data.apiBaseUrl || ''); + setMinimaxModel(data.model || ''); + } catch (error) { + console.error('Failed to load MiniMax config:', error); + } finally { + setIsMinimaxInitialLoading(false); + } + }; + const loadGithubStatus = async () => { try { const result = await getInstallations(); @@ -269,6 +295,35 @@ export default function SettingsDialog({ } }; + const handleSaveMinimaxConfig = () => { + if (!minimaxApiKey.trim() || !minimaxApiBaseUrl.trim()) { + toast.error('Both API key and base URL are required'); + return; + } + setShowMinimaxConfirm(true); + }; + + const handleConfirmSaveMinimaxConfig = async () => { + setShowMinimaxConfirm(false); + setIsMinimaxLoading(true); + try { + await fetchClient.POST('/api/user/config/minimax', { + apiKey: minimaxApiKey, + apiBaseUrl: minimaxApiBaseUrl, + model: minimaxModel.trim() || undefined, + }); + toast.success('MiniMax configuration saved successfully'); + onOpenChange(false); + } catch (error: unknown) { + console.error('Failed to save MiniMax config:', error); + toast.error( + error instanceof Error ? error.message : 'Failed to save MiniMax configuration' + ); + } finally { + setIsMinimaxLoading(false); + } + }; + const handleResetSystemPrompt = () => { setShowSystemPromptResetConfirm(true); }; @@ -355,7 +410,7 @@ export default function SettingsDialog({ className="h-full flex flex-col" > Anthropic + + + MiniMax + + {/* MiniMax Tab */} + +
+
+ + setMinimaxApiBaseUrl(e.target.value)} + disabled={isMinimaxInitialLoading} + className="bg-input border-border text-foreground placeholder:text-muted-foreground disabled:opacity-50 rounded-md focus:ring-2 focus:ring-ring focus:border-ring" + placeholder="https://api.minimax.io/v1" + /> +

+ The base URL for MiniMax API (default: https://api.minimax.io/v1). MiniMax + provides an OpenAI-compatible API. +

+
+ +
+ + setMinimaxApiKey(e.target.value)} + disabled={isMinimaxInitialLoading} + className="bg-input border-border text-foreground placeholder:text-muted-foreground font-mono disabled:opacity-50 rounded-md focus:ring-2 focus:ring-ring focus:border-ring" + placeholder="eyJh..." + /> +

+ Your MiniMax API key. This will be stored securely and injected as + MINIMAX_API_KEY in sandboxes. +

+
+ +
+ + setMinimaxModel(e.target.value)} + disabled={isMinimaxInitialLoading} + className="bg-input border-border text-foreground placeholder:text-muted-foreground font-mono disabled:opacity-50 rounded-md focus:ring-2 focus:ring-ring focus:border-ring" + placeholder="MiniMax-M2.7" + /> +

+ Default model to use (e.g., MiniMax-M2.7, MiniMax-M2.5-highspeed). This will + be injected as MINIMAX_MODEL in sandboxes. +

+
+ +
+ +
+
+
+ {/* GitHub Tab */}
@@ -723,6 +872,32 @@ export default function SettingsDialog({ + {/* MiniMax Config Confirmation Dialog */} + + + + + Confirm Save MiniMax Configuration + + + These changes won't take effect until you manually restart the application. + Save now? + + + + + Cancel + + + Save Configuration + + + + + {/* Anthropic Config Confirmation Dialog */} diff --git a/lib/services/aiproxy.ts b/lib/services/aiproxy.ts index e4f4d61..51040b4 100644 --- a/lib/services/aiproxy.ts +++ b/lib/services/aiproxy.ts @@ -86,14 +86,22 @@ export async function createAiproxyToken( /** * Load environment variables for sandbox from user config * @param userId - User ID - * @returns Environment variables object with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, and model configs + * @returns Environment variables object with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, MiniMax configs, and model configs */ export async function loadEnvVarsForSandbox(userId: string): Promise> { const userConfig = await prisma.userConfig.findMany({ where: { userId, key: { - in: ['ANTHROPIC_API_KEY', 'ANTHROPIC_API', 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL'], + in: [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_API', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_SMALL_FAST_MODEL', + 'MINIMAX_API_KEY', + 'MINIMAX_API', + 'MINIMAX_MODEL', + ], }, }, }) @@ -124,5 +132,23 @@ export async function loadEnvVarsForSandbox(userId: string): Promise config.key === 'MINIMAX_API_KEY') + if (minimaxApiKey?.value) { + envVars.MINIMAX_API_KEY = minimaxApiKey.value + } + + // Find MINIMAX_API and map to MINIMAX_BASE_URL + const minimaxApiBaseUrl = userConfig.find((config) => config.key === 'MINIMAX_API') + if (minimaxApiBaseUrl?.value) { + envVars.MINIMAX_BASE_URL = minimaxApiBaseUrl.value + } + + // Find MINIMAX_MODEL + const minimaxModel = userConfig.find((config) => config.key === 'MINIMAX_MODEL') + if (minimaxModel?.value) { + envVars.MINIMAX_MODEL = minimaxModel.value + } + return envVars } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0bb72a5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { resolve } from 'path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['__tests__/**/*.test.ts'], + }, + resolve: { + alias: { + '@': resolve(__dirname, '.'), + }, + }, +})