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 */}
+
+
+
+
+ API Base URL
+
+
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.
+
+
+
+
+
+ API Key
+
+
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.
+
+
+
+
+
+ Default Model (Optional)
+
+
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.
+
+
+
+
+
+
+ {isMinimaxLoading ? 'Saving...' : 'Save Configuration'}
+
+
+
+
+
{/* 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, '.'),
+ },
+ },
+})