diff --git a/backend/magic-service/app/Application/Provider/Official/ServiceProviderInitializer.php b/backend/magic-service/app/Application/Provider/Official/ServiceProviderInitializer.php index 1518c7051..f84721f00 100644 --- a/backend/magic-service/app/Application/Provider/Official/ServiceProviderInitializer.php +++ b/backend/magic-service/app/Application/Provider/Official/ServiceProviderInitializer.php @@ -306,11 +306,37 @@ private static function getProviderData(string $orgCode): array ]), 'remark' => '', ], + // MiniMax - LLM + [ + 'name' => 'MiniMax', + 'provider_code' => ProviderCode::MiniMax->value, + 'sort_order' => 992, + 'description' => 'MiniMax 是一家领先的 AI 大模型公司,提供高性能的 MiniMax-M2.7 和 MiniMax-M2.5 系列模型,支持百万级超长上下文、工具调用和深度思考能力,通过 OpenAI 兼容接口提供服务。', + 'icon' => 'MAGIC/713471849556451329/default/default.png', + 'provider_type' => 0, + 'category' => 'llm', + 'status' => 1, + 'is_models_enable' => 0, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + 'translate' => json_encode([ + 'name' => [ + 'en_US' => 'MiniMax', + 'zh_CN' => 'MiniMax', + ], + 'description' => [ + 'en_US' => 'MiniMax is a leading AI large model company, providing high-performance MiniMax-M2.7 and MiniMax-M2.5 series models with million-token context, tool calling and deep thinking capabilities, served via an OpenAI-compatible API.', + 'zh_CN' => 'MiniMax 是一家领先的 AI 大模型公司,提供高性能的 MiniMax-M2.7 和 MiniMax-M2.5 系列模型,支持百万级超长上下文、工具调用和深度思考能力,通过 OpenAI 兼容接口提供服务。', + ], + ]), + 'remark' => '', + ], // DeepSeek - LLM [ 'name' => 'DeepSeek', 'provider_code' => ProviderCode::DeepSeek->value, - 'sort_order' => 992, + 'sort_order' => 991, 'description' => 'DeepSeek 是一家专注于 AI 大模型的公司,提供高性能的 AI 语言模型服务。', 'icon' => 'MAGIC/713471849556451329/default/default.png', 'provider_type' => 0, @@ -336,7 +362,7 @@ private static function getProviderData(string $orgCode): array [ 'name' => '自定义提供商', 'provider_code' => ProviderCode::OpenAI->value, - 'sort_order' => 991, + 'sort_order' => 990, 'description' => '请使用接口与 OpenAI API 相同形式的服务商', 'icon' => 'MAGIC/713471849556451329/default/默认图标.png', 'provider_type' => 0, diff --git a/backend/magic-service/app/Domain/Provider/Entity/ValueObject/ProviderCode.php b/backend/magic-service/app/Domain/Provider/Entity/ValueObject/ProviderCode.php index f4f129dab..310163eea 100644 --- a/backend/magic-service/app/Domain/Provider/Entity/ValueObject/ProviderCode.php +++ b/backend/magic-service/app/Domain/Provider/Entity/ValueObject/ProviderCode.php @@ -37,6 +37,7 @@ enum ProviderCode: string case DashScope = 'DashScope'; case OpenRouter = 'OpenRouter'; case SuChuang = 'SuChuang'; + case MiniMax = 'MiniMax'; public function getImplementation(): string { @@ -48,6 +49,7 @@ public function getImplementation(): string self::DeepSeek => DeepSeekModel::class, self::DashScope => DashScopeModel::class, self::OpenRouter => OpenAIModel::class, + self::MiniMax => OpenAIModel::class, default => OpenAIModel::class, }; } @@ -121,6 +123,7 @@ public function getSortOrder(): int self::OpenRouter => 6, self::Volcengine, self::VolcengineArk => 7, self::DeepSeek => 8, + self::MiniMax => 9, default => 999, // 其他服务商排在最后 }; } diff --git a/backend/magic-service/app/Domain/Provider/Entity/ValueObject/ProviderTemplateId.php b/backend/magic-service/app/Domain/Provider/Entity/ValueObject/ProviderTemplateId.php index 1d8fbc868..4ee5d808a 100644 --- a/backend/magic-service/app/Domain/Provider/Entity/ValueObject/ProviderTemplateId.php +++ b/backend/magic-service/app/Domain/Provider/Entity/ValueObject/ProviderTemplateId.php @@ -56,6 +56,9 @@ enum ProviderTemplateId: string case VolcengineArkVlm = '21'; case Gemini = '22'; + // MiniMax 相关 + case MiniMaxLlm = '23'; + /** * 根据ProviderCode和Category获取对应的模板ID. */ @@ -85,6 +88,7 @@ public static function fromProviderCodeAndCategory(ProviderCode $providerCode, C [ProviderCode::Google, Category::VLM] => self::GoogleVlm, [ProviderCode::VolcengineArk, Category::VLM] => self::VolcengineArkVlm, [ProviderCode::Gemini, Category::LLM] => self::Gemini, + [ProviderCode::MiniMax, Category::LLM] => self::MiniMaxLlm, default => null, }; } @@ -120,6 +124,7 @@ public function toProviderCodeAndCategory(): array self::GoogleVlm => ['providerCode' => ProviderCode::Google, 'category' => Category::VLM], self::VolcengineArkVlm => ['providerCode' => ProviderCode::VolcengineArk, 'category' => Category::VLM], self::Gemini => ['providerCode' => ProviderCode::Gemini, 'category' => Category::LLM], + self::MiniMaxLlm => ['providerCode' => ProviderCode::MiniMax, 'category' => Category::LLM], }; } @@ -143,6 +148,7 @@ public function getDescription(): string ProviderCode::Google => 'Google', ProviderCode::VolcengineArk => '火山引擎-方舟', ProviderCode::Gemini => 'Google Gemini', + ProviderCode::MiniMax => 'MiniMax', default => '未知服务商', }; diff --git a/backend/magic-service/app/Domain/Provider/Service/ConnectivityTest/LLM/LLMMiniMaxProvider.php b/backend/magic-service/app/Domain/Provider/Service/ConnectivityTest/LLM/LLMMiniMaxProvider.php new file mode 100644 index 000000000..1d533f83a --- /dev/null +++ b/backend/magic-service/app/Domain/Provider/Service/ConnectivityTest/LLM/LLMMiniMaxProvider.php @@ -0,0 +1,88 @@ +setStatus(true); + $apiKey = $serviceProviderConfig->getApiKey(); + if (empty($apiKey)) { + $connectResponse->setStatus(false); + $connectResponse->setMessage(__('service_provider.api_key_empty')); + return $connectResponse; + } + try { + $this->testChatCompletion($apiKey, $modelVersion); + } catch (Exception $e) { + $connectResponse->setStatus(false); + if ($e instanceof ClientException) { + $connectResponse->setMessage(Json::decode($e->getResponse()->getBody()->getContents())); + } else { + $connectResponse->setMessage($e->getMessage()); + } + } + + return $connectResponse; + } + + /** + * Test connectivity by sending a minimal chat completion request. + * + * This validates both the API key and that the specific model version + * is accessible, rather than just listing available models. + * MiniMax requires temperature strictly in (0.0, 1.0]. + */ + protected function testChatCompletion(string $apiKey, string $modelVersion): array + { + $client = new Client(); + $payload = [ + 'model' => $modelVersion, + 'max_tokens' => 1, + 'temperature' => 0.01, + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'Hi', + ], + ], + ]; + + $response = $client->request('POST', $this->apiBase . '/chat/completions', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + return Json::decode($response->getBody()->getContents()); + } +} diff --git a/backend/magic-service/test/Cases/Unit/Provider/LLMMiniMaxProviderTest.php b/backend/magic-service/test/Cases/Unit/Provider/LLMMiniMaxProviderTest.php new file mode 100644 index 000000000..2726ab34b --- /dev/null +++ b/backend/magic-service/test/Cases/Unit/Provider/LLMMiniMaxProviderTest.php @@ -0,0 +1,174 @@ +setAccessible(true); + $this->assertSame('https://api.minimax.io/v1', $reflection->getValue($provider)); + } + + public function testConnectivityTestFailsWithEmptyApiKey(): void + { + $provider = new LLMMiniMaxProvider(); + $config = new ProviderConfigItem([]); + + $response = $provider->connectivityTestByModel($config, 'MiniMax-M2.7'); + + $this->assertFalse($response->getStatus()); + } + + public function testConnectivityTestSucceedsWithValidApiKey(): void + { + // Create a mock HTTP handler that returns a successful response + $mock = new MockHandler([ + new Response(200, [], json_encode([ + 'object' => 'list', + 'data' => [ + ['id' => 'MiniMax-M2.7', 'object' => 'model'], + ['id' => 'MiniMax-M2.5', 'object' => 'model'], + ], + ])), + ]); + + $handlerStack = HandlerStack::create($mock); + $mockClient = new Client(['handler' => $handlerStack]); + + // Use a subclass to inject the mock client + $provider = new class($mockClient) extends LLMMiniMaxProvider { + private Client $mockClient; + + public function __construct(Client $mockClient) + { + $this->mockClient = $mockClient; + } + + protected function fetchModels(string $apiKey): array + { + $response = $this->mockClient->request('GET', $this->apiBase . '/models', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + ], + ]); + + return json_decode($response->getBody()->getContents(), true); + } + }; + + $config = new ProviderConfigItem(['api_key' => 'test-valid-key']); + + $response = $provider->connectivityTestByModel($config, 'MiniMax-M2.7'); + + $this->assertTrue($response->getStatus()); + } + + public function testConnectivityTestFailsWithInvalidApiKey(): void + { + // Create a mock HTTP handler that returns an authentication error + $mock = new MockHandler([ + new \GuzzleHttp\Exception\ClientException( + 'Client error', + new \GuzzleHttp\Psr7\Request('GET', 'https://api.minimax.io/v1/models'), + new Response(401, [], json_encode([ + 'error' => [ + 'message' => 'Invalid API key', + 'type' => 'authentication_error', + ], + ])) + ), + ]); + + $handlerStack = HandlerStack::create($mock); + $mockClient = new Client(['handler' => $handlerStack]); + + $provider = new class($mockClient) extends LLMMiniMaxProvider { + private Client $mockClient; + + public function __construct(Client $mockClient) + { + $this->mockClient = $mockClient; + } + + protected function fetchModels(string $apiKey): array + { + $response = $this->mockClient->request('GET', $this->apiBase . '/models', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + ], + ]); + + return json_decode($response->getBody()->getContents(), true); + } + }; + + $config = new ProviderConfigItem(['api_key' => 'invalid-key']); + + $response = $provider->connectivityTestByModel($config, 'MiniMax-M2.7'); + + $this->assertFalse($response->getStatus()); + } + + public function testConnectivityTestFailsOnNetworkError(): void + { + // Create a mock HTTP handler that throws a network exception + $mock = new MockHandler([ + new \GuzzleHttp\Exception\ConnectException( + 'Connection refused', + new \GuzzleHttp\Psr7\Request('GET', 'https://api.minimax.io/v1/models') + ), + ]); + + $handlerStack = HandlerStack::create($mock); + $mockClient = new Client(['handler' => $handlerStack]); + + $provider = new class($mockClient) extends LLMMiniMaxProvider { + private Client $mockClient; + + public function __construct(Client $mockClient) + { + $this->mockClient = $mockClient; + } + + protected function fetchModels(string $apiKey): array + { + $response = $this->mockClient->request('GET', $this->apiBase . '/models', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + ], + ]); + + return json_decode($response->getBody()->getContents(), true); + } + }; + + $config = new ProviderConfigItem(['api_key' => 'test-key']); + + $response = $provider->connectivityTestByModel($config, 'MiniMax-M2.7'); + + $this->assertFalse($response->getStatus()); + $this->assertNotEmpty($response->getMessage()); + } +} diff --git a/backend/magic-service/test/Cases/Unit/Provider/ProviderCodeMiniMaxTest.php b/backend/magic-service/test/Cases/Unit/Provider/ProviderCodeMiniMaxTest.php new file mode 100644 index 000000000..ca12a778e --- /dev/null +++ b/backend/magic-service/test/Cases/Unit/Provider/ProviderCodeMiniMaxTest.php @@ -0,0 +1,113 @@ +assertSame('MiniMax', $provider->value); + } + + public function testMiniMaxCanBeCreatedFromString(): void + { + $provider = ProviderCode::from('MiniMax'); + $this->assertSame(ProviderCode::MiniMax, $provider); + } + + public function testMiniMaxTryFromReturnsInstance(): void + { + $provider = ProviderCode::tryFrom('MiniMax'); + $this->assertNotNull($provider); + $this->assertSame(ProviderCode::MiniMax, $provider); + } + + public function testMiniMaxUsesOpenAIModelImplementation(): void + { + $implementation = ProviderCode::MiniMax->getImplementation(); + $this->assertSame(OpenAIModel::class, $implementation); + } + + public function testMiniMaxIsNotOfficial(): void + { + $this->assertFalse(ProviderCode::MiniMax->isOfficial()); + } + + public function testMiniMaxSortOrder(): void + { + $sortOrder = ProviderCode::MiniMax->getSortOrder(); + $this->assertSame(9, $sortOrder); + // MiniMax should be after DeepSeek (8) and before the default (999) + $this->assertGreaterThan(ProviderCode::DeepSeek->getSortOrder(), $sortOrder); + $this->assertLessThan(ProviderCode::None->getSortOrder(), $sortOrder); + } + + public function testMiniMaxImplementationConfigUsesDefaultCase(): void + { + $config = $this->createMock(\App\Domain\Provider\DTO\Item\ProviderConfigItem::class); + $config->method('getApiKey')->willReturn('test-api-key'); + $config->method('getUrl')->willReturn('https://api.minimax.io/v1'); + + $implementationConfig = ProviderCode::MiniMax->getImplementationConfig($config); + + $this->assertArrayHasKey('api_key', $implementationConfig); + $this->assertArrayHasKey('base_url', $implementationConfig); + $this->assertArrayHasKey('auto_cache_config', $implementationConfig); + $this->assertSame('test-api-key', $implementationConfig['api_key']); + $this->assertSame('https://api.minimax.io/v1', $implementationConfig['base_url']); + } + + public function testMiniMaxTemplateIdExists(): void + { + $templateId = ProviderTemplateId::MiniMaxLlm; + $this->assertSame('23', $templateId->value); + } + + public function testMiniMaxTemplateIdFromProviderCodeAndCategory(): void + { + $templateId = ProviderTemplateId::fromProviderCodeAndCategory( + ProviderCode::MiniMax, + Category::LLM + ); + $this->assertNotNull($templateId); + $this->assertSame(ProviderTemplateId::MiniMaxLlm, $templateId); + } + + public function testMiniMaxTemplateIdToProviderCodeAndCategory(): void + { + $result = ProviderTemplateId::MiniMaxLlm->toProviderCodeAndCategory(); + $this->assertSame(ProviderCode::MiniMax, $result['providerCode']); + $this->assertSame(Category::LLM, $result['category']); + } + + public function testMiniMaxTemplateIdDescription(): void + { + $description = ProviderTemplateId::MiniMaxLlm->getDescription(); + $this->assertStringContainsString('MiniMax', $description); + } + + public function testMiniMaxVlmTemplateIdReturnsNull(): void + { + // MiniMax only supports LLM, not VLM + $templateId = ProviderTemplateId::fromProviderCodeAndCategory( + ProviderCode::MiniMax, + Category::VLM + ); + $this->assertNull($templateId); + } +} diff --git a/backend/magic-service/test/Cases/Unit/Provider/ServiceProviderInitializerMiniMaxTest.php b/backend/magic-service/test/Cases/Unit/Provider/ServiceProviderInitializerMiniMaxTest.php new file mode 100644 index 000000000..c6d0aba07 --- /dev/null +++ b/backend/magic-service/test/Cases/Unit/Provider/ServiceProviderInitializerMiniMaxTest.php @@ -0,0 +1,111 @@ +setAccessible(true); + + $providers = $method->invoke(null, 'test-org-code'); + + // Find MiniMax provider in the data + $miniMaxProviders = array_filter($providers, function ($provider) { + return $provider['provider_code'] === ProviderCode::MiniMax->value; + }); + + $this->assertNotEmpty($miniMaxProviders, 'MiniMax provider should exist in provider data'); + } + + public function testMiniMaxProviderDataHasCorrectFields(): void + { + $method = new ReflectionMethod(ServiceProviderInitializer::class, 'getProviderData'); + $method->setAccessible(true); + + $providers = $method->invoke(null, 'test-org-code'); + + $miniMaxProvider = null; + foreach ($providers as $provider) { + if ($provider['provider_code'] === ProviderCode::MiniMax->value) { + $miniMaxProvider = $provider; + break; + } + } + + $this->assertNotNull($miniMaxProvider, 'MiniMax provider should exist'); + $this->assertSame('MiniMax', $miniMaxProvider['name']); + $this->assertSame('MiniMax', $miniMaxProvider['provider_code']); + $this->assertSame('llm', $miniMaxProvider['category']); + $this->assertSame(0, $miniMaxProvider['provider_type']); // Non-official + $this->assertSame(1, $miniMaxProvider['status']); // Enabled + } + + public function testMiniMaxProviderDataHasTranslations(): void + { + $method = new ReflectionMethod(ServiceProviderInitializer::class, 'getProviderData'); + $method->setAccessible(true); + + $providers = $method->invoke(null, 'test-org-code'); + + $miniMaxProvider = null; + foreach ($providers as $provider) { + if ($provider['provider_code'] === ProviderCode::MiniMax->value) { + $miniMaxProvider = $provider; + break; + } + } + + $this->assertNotNull($miniMaxProvider); + + $translate = json_decode($miniMaxProvider['translate'], true); + $this->assertArrayHasKey('name', $translate); + $this->assertArrayHasKey('description', $translate); + + // Check English translations + $this->assertSame('MiniMax', $translate['name']['en_US']); + $this->assertSame('MiniMax', $translate['name']['zh_CN']); + + // Check description contains MiniMax model info + $this->assertStringContainsString('MiniMax', $translate['description']['en_US']); + $this->assertStringContainsString('M2.7', $translate['description']['en_US']); + $this->assertStringContainsString('OpenAI-compatible', $translate['description']['en_US']); + } + + public function testMiniMaxProviderSortOrderIsValid(): void + { + $method = new ReflectionMethod(ServiceProviderInitializer::class, 'getProviderData'); + $method->setAccessible(true); + + $providers = $method->invoke(null, 'test-org-code'); + + $sortOrders = []; + foreach ($providers as $provider) { + if ($provider['category'] === 'llm') { + $sortOrders[$provider['provider_code']] = $provider['sort_order']; + } + } + + // MiniMax should have a valid sort order that is unique among LLM providers + $this->assertArrayHasKey('MiniMax', $sortOrders); + $this->assertGreaterThan(0, $sortOrders['MiniMax']); + + // Each sort_order should be unique within LLM category + $llmSortOrders = array_values($sortOrders); + $this->assertSame(count($llmSortOrders), count(array_unique($llmSortOrders)), 'Sort orders should be unique'); + } +} diff --git a/frontend/magic-admin/src/const/aiModel.ts b/frontend/magic-admin/src/const/aiModel.ts index 26ef141d3..d0bbd6499 100644 --- a/frontend/magic-admin/src/const/aiModel.ts +++ b/frontend/magic-admin/src/const/aiModel.ts @@ -52,6 +52,8 @@ export namespace AiModel { QwenGlobal = "QwenGlobal", /** OpenRouter */ OpenRouter = "OpenRouter", + /** MiniMax */ + MiniMax = "MiniMax", } /** 服务商默认 URL */ @@ -64,6 +66,7 @@ export namespace AiModel { [ServiceProvider.Gemini]: "https://api.gemini.com", [ServiceProvider.GoogleImage]: "https://api.googleimage.com", [ServiceProvider.OpenRouter]: "https://openrouter.ai/api/v1/chat/completions", + [ServiceProvider.MiniMax]: "https://api.minimax.io/v1", } /** 权限 */