diff --git a/README-zh.md b/README-zh.md index bd73a80a..a9b1b398 100644 --- a/README-zh.md +++ b/README-zh.md @@ -109,7 +109,7 @@ providers: apiKey: sk-ant-... ``` -支持的服务商:**OpenAI**、**Anthropic**、**Google Gemini**、**DeepSeek** 以及任何兼容 OpenAI API 的服务。 +支持的服务商:**OpenAI**、**Anthropic**、**Google Gemini**、**DeepSeek**、**MiniMax** 以及任何兼容 OpenAI API 的服务。 > **推荐模型:** **Gemini 3 Flash** — 效果与速度的最佳平衡。追求最高质量可选 **Gemini 3.1 Pro**(速度较慢)。 > diff --git a/README.md b/README.md index 866f1bcc..6b9271a9 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ providers: apiKey: sk-ant-... ``` -Supported providers: **OpenAI**, **Anthropic**, **Google Gemini**, **DeepSeek**, and any OpenAI-compatible API. +Supported providers: **OpenAI**, **Anthropic**, **Google Gemini**, **DeepSeek**, **MiniMax**, and any OpenAI-compatible API. > **Recommended model:** **Gemini 3 Flash** — best balance of quality and speed. For highest quality (at slower speed), try **Gemini 3.1 Pro**. > diff --git a/lib/ai/__tests__/providers.integration.test.ts b/lib/ai/__tests__/providers.integration.test.ts new file mode 100644 index 00000000..3ea71aa6 --- /dev/null +++ b/lib/ai/__tests__/providers.integration.test.ts @@ -0,0 +1,100 @@ +/** + * Integration tests for MiniMax provider — validates that getModel() + * creates a valid LanguageModel instance via the Anthropic SDK adapter. + * + * These tests do NOT make real API calls; they verify that the provider + * configuration integrates correctly with the Vercel AI SDK factory + * functions and that the resulting model object has the expected shape. + */ + +import { describe, it, expect } from 'vitest'; +import { getModel, PROVIDERS } from '../providers'; + +describe('MiniMax getModel() integration', () => { + it('should create a model instance for MiniMax-M2.7', () => { + const { model, modelInfo } = getModel({ + providerId: 'minimax', + modelId: 'MiniMax-M2.7', + apiKey: 'test-key-not-used', + }); + + expect(model).toBeDefined(); + expect(model.modelId).toBe('MiniMax-M2.7'); + expect(modelInfo).toBeDefined(); + expect(modelInfo!.name).toBe('MiniMax M2.7'); + }); + + it('should create a model instance for MiniMax-M2.5-highspeed', () => { + const { model, modelInfo } = getModel({ + providerId: 'minimax', + modelId: 'MiniMax-M2.5-highspeed', + apiKey: 'test-key-not-used', + }); + + expect(model).toBeDefined(); + expect(model.modelId).toBe('MiniMax-M2.5-highspeed'); + expect(modelInfo).toBeDefined(); + expect(modelInfo!.name).toBe('MiniMax M2.5 Highspeed'); + }); + + it('should create a model instance for MiniMax-M2.5', () => { + const { model, modelInfo } = getModel({ + providerId: 'minimax', + modelId: 'MiniMax-M2.5', + apiKey: 'test-key-not-used', + }); + + expect(model).toBeDefined(); + expect(model.modelId).toBe('MiniMax-M2.5'); + expect(modelInfo).toBeDefined(); + }); + + it('should throw when API key is missing', () => { + expect(() => + getModel({ + providerId: 'minimax', + modelId: 'MiniMax-M2.7', + apiKey: '', + }), + ).toThrow('API key required for provider: minimax'); + }); + + it('should use the provider default base URL', () => { + const provider = PROVIDERS.minimax; + expect(provider.defaultBaseUrl).toBe( + 'https://api.minimaxi.com/anthropic/v1', + ); + + // The model is created with the provider's default base URL + const { model } = getModel({ + providerId: 'minimax', + modelId: 'MiniMax-M2.7', + apiKey: 'test-key-not-used', + }); + expect(model).toBeDefined(); + }); + + it('should allow overriding the base URL', () => { + const { model } = getModel({ + providerId: 'minimax', + modelId: 'MiniMax-M2.7', + apiKey: 'test-key-not-used', + baseUrl: 'https://custom-proxy.example.com/v1', + }); + expect(model).toBeDefined(); + }); + + it('should use anthropic provider type for model creation', () => { + // Verify the provider type is 'anthropic' which routes through createAnthropic() + expect(PROVIDERS.minimax.type).toBe('anthropic'); + + // The model should be created successfully via the anthropic code path + const { model } = getModel({ + providerId: 'minimax', + modelId: 'MiniMax-M2.7', + apiKey: 'test-key-not-used', + }); + expect(model).toBeDefined(); + expect(model.provider).toContain('anthropic'); + }); +}); diff --git a/lib/ai/__tests__/providers.test.ts b/lib/ai/__tests__/providers.test.ts new file mode 100644 index 00000000..bcce0210 --- /dev/null +++ b/lib/ai/__tests__/providers.test.ts @@ -0,0 +1,163 @@ +/** + * Unit tests for AI providers configuration — MiniMax provider + */ + +import { describe, it, expect } from 'vitest'; +import { + PROVIDERS, + getProvider, + getModelInfo, + parseModelString, + getAllModels, +} from '../providers'; + +describe('MiniMax provider configuration', () => { + it('should be registered as a built-in provider', () => { + expect(PROVIDERS.minimax).toBeDefined(); + expect(PROVIDERS.minimax.id).toBe('minimax'); + expect(PROVIDERS.minimax.name).toBe('MiniMax'); + }); + + it('should use the Anthropic-compatible API type', () => { + expect(PROVIDERS.minimax.type).toBe('anthropic'); + }); + + it('should have a valid default base URL', () => { + expect(PROVIDERS.minimax.defaultBaseUrl).toBe( + 'https://api.minimaxi.com/anthropic/v1', + ); + }); + + it('should require an API key', () => { + expect(PROVIDERS.minimax.requiresApiKey).toBe(true); + }); + + it('should have an icon path', () => { + expect(PROVIDERS.minimax.icon).toBe('/logos/minimax.svg'); + }); + + it('should include MiniMax-M2.7 as the flagship model', () => { + const m27 = PROVIDERS.minimax.models.find( + (m) => m.id === 'MiniMax-M2.7', + ); + expect(m27).toBeDefined(); + expect(m27!.name).toBe('MiniMax M2.7'); + expect(m27!.contextWindow).toBe(204800); + expect(m27!.outputWindow).toBe(8192); + }); + + it('should list MiniMax-M2.7 as the first (top) model', () => { + expect(PROVIDERS.minimax.models[0].id).toBe('MiniMax-M2.7'); + }); + + it('should include MiniMax-M2.5', () => { + const m25 = PROVIDERS.minimax.models.find( + (m) => m.id === 'MiniMax-M2.5', + ); + expect(m25).toBeDefined(); + expect(m25!.name).toBe('MiniMax M2.5'); + }); + + it('should include MiniMax-M2.5-highspeed', () => { + const m25hs = PROVIDERS.minimax.models.find( + (m) => m.id === 'MiniMax-M2.5-highspeed', + ); + expect(m25hs).toBeDefined(); + expect(m25hs!.name).toBe('MiniMax M2.5 Highspeed'); + expect(m25hs!.contextWindow).toBe(204800); + }); + + it('should include legacy models (M2.1, M2.1-lightning, M2)', () => { + const m21 = PROVIDERS.minimax.models.find( + (m) => m.id === 'MiniMax-M2.1', + ); + const m21l = PROVIDERS.minimax.models.find( + (m) => m.id === 'MiniMax-M2.1-lightning', + ); + const m2 = PROVIDERS.minimax.models.find((m) => m.id === 'MiniMax-M2'); + expect(m21).toBeDefined(); + expect(m21l).toBeDefined(); + expect(m2).toBeDefined(); + }); + + it('should have 6 models in total', () => { + expect(PROVIDERS.minimax.models).toHaveLength(6); + }); + + it('all models should support streaming and tools', () => { + for (const model of PROVIDERS.minimax.models) { + expect(model.capabilities?.streaming).toBe(true); + expect(model.capabilities?.tools).toBe(true); + } + }); + + it('no models should claim vision support', () => { + for (const model of PROVIDERS.minimax.models) { + expect(model.capabilities?.vision).toBe(false); + } + }); +}); + +describe('getProvider() for MiniMax', () => { + it('should return the MiniMax provider', () => { + const provider = getProvider('minimax'); + expect(provider).toBeDefined(); + expect(provider!.id).toBe('minimax'); + }); +}); + +describe('getModelInfo() for MiniMax models', () => { + it('should return info for MiniMax-M2.7', () => { + const info = getModelInfo('minimax', 'MiniMax-M2.7'); + expect(info).toBeDefined(); + expect(info!.name).toBe('MiniMax M2.7'); + }); + + it('should return info for MiniMax-M2.5-highspeed', () => { + const info = getModelInfo('minimax', 'MiniMax-M2.5-highspeed'); + expect(info).toBeDefined(); + expect(info!.name).toBe('MiniMax M2.5 Highspeed'); + }); + + it('should return undefined for unknown model', () => { + const info = getModelInfo('minimax', 'MiniMax-M99'); + expect(info).toBeUndefined(); + }); +}); + +describe('parseModelString() with MiniMax', () => { + it('should parse "minimax:MiniMax-M2.7"', () => { + const result = parseModelString('minimax:MiniMax-M2.7'); + expect(result.providerId).toBe('minimax'); + expect(result.modelId).toBe('MiniMax-M2.7'); + }); + + it('should parse "minimax:MiniMax-M2.5-highspeed"', () => { + const result = parseModelString('minimax:MiniMax-M2.5-highspeed'); + expect(result.providerId).toBe('minimax'); + expect(result.modelId).toBe('MiniMax-M2.5-highspeed'); + }); +}); + +describe('getAllModels() includes MiniMax', () => { + it('should include MiniMax in the grouped results', () => { + const all = getAllModels(); + const minimaxGroup = all.find((g) => g.provider.id === 'minimax'); + expect(minimaxGroup).toBeDefined(); + expect(minimaxGroup!.models.length).toBeGreaterThanOrEqual(6); + }); +}); + +describe('MiniMax model ordering', () => { + it('should list models from newest to oldest', () => { + const ids = PROVIDERS.minimax.models.map((m) => m.id); + expect(ids).toEqual([ + 'MiniMax-M2.7', + 'MiniMax-M2.5', + 'MiniMax-M2.5-highspeed', + 'MiniMax-M2.1', + 'MiniMax-M2.1-lightning', + 'MiniMax-M2', + ]); + }); +}); diff --git a/lib/ai/providers.ts b/lib/ai/providers.ts index 05d167ee..0b8cf96f 100644 --- a/lib/ai/providers.ts +++ b/lib/ai/providers.ts @@ -668,6 +668,13 @@ export const PROVIDERS: Record = { requiresApiKey: true, icon: '/logos/minimax.svg', models: [ + { + id: 'MiniMax-M2.7', + name: 'MiniMax M2.7', + contextWindow: 204800, + outputWindow: 8192, + capabilities: { streaming: true, tools: true, vision: false }, + }, { id: 'MiniMax-M2.5', name: 'MiniMax M2.5', @@ -675,6 +682,13 @@ export const PROVIDERS: Record = { outputWindow: 8192, capabilities: { streaming: true, tools: true, vision: false }, }, + { + id: 'MiniMax-M2.5-highspeed', + name: 'MiniMax M2.5 Highspeed', + contextWindow: 204800, + outputWindow: 8192, + capabilities: { streaming: true, tools: true, vision: false }, + }, { id: 'MiniMax-M2.1', name: 'MiniMax M2.1', diff --git a/package.json b/package.json index ed502a52..779e69e3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "start": "next start", "lint": "eslint", "check": "prettier . --check", - "format": "prettier . --write" + "format": "prettier . --write", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.23", @@ -114,6 +116,7 @@ "tailwindcss": "^4", "tslib": "^2.8.0", "typescript": "^5", + "vitest": "^4.1.0", "vue-to-react": "^1.0.0" }, "pnpm": { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..1d7b68f8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, + test: { + include: ['**/*.test.ts'], + environment: 'node', + }, +});