diff --git a/services/nginx/app/classes/minimax.php b/services/nginx/app/classes/minimax.php new file mode 100644 index 00000000..26947ffe --- /dev/null +++ b/services/nginx/app/classes/minimax.php @@ -0,0 +1,196 @@ +config = new miniMax_c(); + } + + public function requireModuleEnabled(): void + { + if (!$this->config->enabled->isTrue()) { + throw new Exception('MiniMax module is not enabled.'); + } + $apiKey = trim((string)$this->config->api_key->getVariableValue()); + if ($apiKey === '') { + throw new Exception('MiniMax API key is not configured.'); + } + } + + /** + * Send a structured JSON text task to MiniMax M3 (Anthropic-messages format). + * + * Returns the parsed JSON object plus `_minimax_response_model` and `_minimax_usage` + * so the autopilot can compare against the resolved model id and track tokens. + * + * @throws Exception + */ + public function jsonTask( + string $schemaName, + string $prompt, + array $payload, + array $schema, + float $temperature = 0.1, + ?string $model = null + ): array { + $this->requireModuleEnabled(); + + // Anthropic-messages uses a single `messages` array, system prompt is separate, + // and structured output goes in `tools` with `input_schema`. + $data = [ + 'model' => $model ?? $this->model, + 'max_tokens' => (int)$this->max_tokens, + 'temperature' => $temperature, + 'system' => $prompt, + 'messages' => [ + [ + 'role' => 'user', + 'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ], + ], + 'tools' => [ + [ + 'name' => $schemaName, + 'description' => 'Return the structured decision for the XL Vask automation planner.', + 'input_schema' => $schema, + ], + ], + // Force the model to call the tool — guarantees a structured JSON object back. + 'tool_choice' => ['type' => 'tool', 'name' => $schemaName], + ]; + + $response = $this->sendRequest($data); + return self::parseJsonTaskResponse($response, $schemaName); + } + + public static function parseJsonTaskResponse(array $response, string $expectedToolName): array + { + // Anthropic-messages stop_reason: end_turn | tool_use | max_tokens | stop_sequence + $stopReason = (string)($response['stop_reason'] ?? ''); + if ($stopReason === 'max_tokens') { + throw new minimax_request_exception('MiniMax response was truncated by max_tokens.', true); + } + if (!in_array($stopReason, ['end_turn', 'tool_use'], true)) { + throw new minimax_request_exception('MiniMax response did not complete (stop_reason=' . $stopReason . ').', true); + } + + $toolInput = null; + $toolName = null; + foreach ((array)($response['content'] ?? []) as $block) { + if (($block['type'] ?? null) === 'tool_use') { + $toolName = (string)($block['name'] ?? ''); + $toolInput = (array)($block['input'] ?? []); + break; + } + } + if ($toolInput === null) { + throw new minimax_request_exception('MiniMax completed without a structured tool_use block.', false); + } + if ($toolName !== $expectedToolName) { + throw new minimax_request_exception( + 'MiniMax returned tool "' . $toolName . '", expected "' . $expectedToolName . '".', + false + ); + } + + $resolvedModel = trim((string)($response['model'] ?? '')); + if ($resolvedModel === '') { + throw new minimax_request_exception('MiniMax response omitted the resolved model.', false); + } + $usage = (array)($response['usage'] ?? []); + $inputTokens = max(0, (int)($usage['input_tokens'] ?? 0)); + $outputTokens = max(0, (int)($usage['output_tokens'] ?? 0)); + // The legacy `_openai_*` aliases keep the autopilot's sanitizeOpenAiResult() working + // unchanged — it reads those keys regardless of which provider produced the result. + return [ + ...$toolInput, + '_minimax_response_model' => $resolvedModel, + '_minimax_usage' => [ + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'total_tokens' => $inputTokens + $outputTokens, + 'service_tier' => '', + ], + '_openai_response_model' => $resolvedModel, + '_openai_usage' => [ + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'total_tokens' => $inputTokens + $outputTokens, + 'service_tier' => '', + ], + ]; + } + + /** + * @throws Exception + */ + private function sendRequest(array $data): array + { + $this->requireModuleEnabled(); + $curl = curl_init($this->api_url); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_POST, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($curl, CURLOPT_TIMEOUT, 60); + // MiniMax uses Anthropic-style auth headers + curl_setopt($curl, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'x-api-key: ' . $this->config->api_key->getVariableValue(), + 'anthropic-version: 2023-06-01', + ]); + curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data)); + $response = curl_exec($curl); + if (curl_errno($curl)) { + $curlCode = curl_errno($curl); + curl_close($curl); + throw new minimax_request_exception( + 'MiniMax transport failed.', + in_array($curlCode, [CURLE_OPERATION_TIMEDOUT, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST], true) + ); + } + $httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + curl_close($curl); + $responseData = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new minimax_request_exception('MiniMax returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus); + } + if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) { + throw new minimax_request_exception('MiniMax request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus); + } + return $responseData; + } +} diff --git a/services/nginx/app/classes/xlvask_automation_service.php b/services/nginx/app/classes/xlvask_automation_service.php index b8008ea9..712cd8a7 100644 --- a/services/nginx/app/classes/xlvask_automation_service.php +++ b/services/nginx/app/classes/xlvask_automation_service.php @@ -35,7 +35,7 @@ class xlvask_automation_service private const CREATE_MIN_AGE_HOURS = 6.0; private const OPENAI_CACHE_VERSION = 2; public const POLICY_VERSION = 'xlvask-ai-auto-v2'; - private const PLANNER_MODEL = 'gpt-5.6-sol'; + private const PLANNER_MODEL = 'MiniMax-M3'; private const PLANNER_PROMPT_VERSION = 'xlvask-planner-da-v2'; private const PLANNER_SCHEMA_VERSION = 'xlvask-automation-schema-v2'; private const PLANNER_PROMPT = 'Vurder om en XL Vask-vask skal tilknyttes en eksisterende ordre, oprettes som ordre eller ikke behandles. Returner kun JSON efter skemaet.'; @@ -1049,7 +1049,7 @@ class xlvask_automation_service if ($this->openAiBudgetReached()) { return null; } - $openai = new openai(); + $openai = new minimax(); $result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature, self::PLANNER_MODEL); $result = $this->sanitizeOpenAiResult($result, $context); $this->recordOpenAiUsage((array)($result['usage'] ?? [])); @@ -2804,11 +2804,11 @@ class xlvask_automation_service { try { $xlvask = new xlvask(); - if (!$xlvask->config->openai_integration_enabled->isTrue()) { + if (!$xlvask->config->minimax_integration_enabled->isTrue()) { return false; } - $openai = new openai(); + $openai = new minimax(); return $openai->config->enabled->isTrue(); } catch (Exception) { return false; diff --git a/services/nginx/app/modules/miniMax/config/miniMax_api_key_c.php b/services/nginx/app/modules/miniMax/config/miniMax_api_key_c.php new file mode 100644 index 00000000..0cd9293f --- /dev/null +++ b/services/nginx/app/modules/miniMax/config/miniMax_api_key_c.php @@ -0,0 +1,29 @@ +setupConfig('miniMax'); + $this->allowUpdate([ + miniMax_enabled_c::class, + miniMax_api_key_c::class, + ]); + $this->enabled = new miniMax_enabled_c(); + $this->api_key = new miniMax_api_key_c(); + } +} diff --git a/services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php b/services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php new file mode 100644 index 00000000..c855351c --- /dev/null +++ b/services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php @@ -0,0 +1,29 @@ +synchronization_enabled = new xlvask_synchronization_enabled_c(); $this->automatic_order_attachment_enabled = new xlvask_automatic_order_attachment_enabled_c(); $this->automatic_order_creation_enabled = new xlvask_automatic_order_creation_enabled_c(); + $this->minimax_integration_enabled = new xlvask_minimax_integration_enabled_c(); $this->openai_integration_enabled = new xlvask_openai_integration_enabled_c(); $this->username = new xlvask_username_c(); $this->password = new xlvask_password_c(); diff --git a/services/nginx/app/routes/moduleConfigRoute.php b/services/nginx/app/routes/moduleConfigRoute.php index bb240b0a..b99546dc 100644 --- a/services/nginx/app/routes/moduleConfigRoute.php +++ b/services/nginx/app/routes/moduleConfigRoute.php @@ -952,6 +952,44 @@ class moduleConfigRoute 'modules_openai_config' => 'Update openai config' ] ); + /** MiniMax config > GET */ + $this->get('/minimax/config', function () { + global $response; + $this->requirePermission('modules_minimax_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully fetched MiniMax config'); + $response->success( + (new \classes\minimax())->config->getConfigRequest() + ); + } else { + (new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_minimax_config' => 'Get MiniMax config' + ] + ); + /** MiniMax config > POST */ + $this->post('/minimax/config', function () { + global $response; + $this->requirePermission('modules_minimax_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully updated MiniMax config'); + $response->success( + (new \classes\minimax())->config->postConfigRequest() + ); + } else { + (new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_minimax_config' => 'Update MiniMax config' + ] + ); /** LicensePlateRecognizer config > GET */ $this->get('/licenseplaterecognizer/config', function () { global $response; diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php index 6e9bae38..32cebf07 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php @@ -250,7 +250,7 @@ it('uses the existing OpenAI module with strict no-retention planner settings', ->toContain("if (\$status !== 'completed')") ->toContain("=== 'refusal'") ->toContain("'_openai_usage' => [") - ->and($automation)->toContain("private const PLANNER_MODEL = 'gpt-5.6-sol'") + ->and($automation)->toContain("private const PLANNER_MODEL = 'MiniMax-M3'") ->toContain('candidate_order_id') ->not->toContain('opaque_context_id') ->not->toContain("'product_name' =>"); @@ -377,7 +377,7 @@ it('pins the complete planner identity and invalidates cache keys with it', func expect($identity) ->toMatchArray([ 'policy_version' => 'xlvask-ai-auto-v2', - 'model' => 'gpt-5.6-sol', + 'model' => 'MiniMax-M3', 'prompt_version' => 'xlvask-planner-da-v2', 'schema_version' => 'xlvask-automation-schema-v2', 'cache_version' => 2,