feat(api): add MiniMax M3 client and force XL Vask autopilot to use it (#355)

Adds a new `modules/miniMax` module mirroring the existing OpenAI
pattern (`api_key` + `enabled` config keys). Adds a
`classes/minimax.php` client that calls
`https://api.minimax.io/anthropic/v1/messages` (the same endpoint
OpenClaw's minimax-portal provider uses) and returns structured JSON via
the Anthropic tool_use response shape.

**Replaces the autopilot planner:**
- `PLANNER_MODEL`: `gpt-5.6-sol` → `MiniMax-M3`
- `xlvask_automation_service`: `new openai()` → `new minimax()` in the
planner + the isOpenAiIntegrationEnabled() guard
- New xlvask config flag `minimax_integration_enabled` gates the
autopilot. The OpenAI flag is kept so deployments can roll back.

**Compatibility shims** (so the autopilot keeps working with minimal
churn):
- `minimax_request_exception` extends `openai_request_exception`, so
every existing `catch (openai_request_exception)` block catches MiniMax
errors unchanged.
- The result payload also carries the legacy `_openai_response_model`
and `_openai_usage` aliases, so `sanitizeOpenAiResult` keeps working
unchanged.

**New endpoints:** GET/POST `/minimax/config` in `moduleConfigRoute`
guarded by `modules_minimax_config`, mirroring `/openai/config`.

**Tests:** PHP api suite 290/290 passing locally (no regressions). All
xlvask tests still pass.

**Frontend counterpart** ships in a separate PR on pleno-vue
(ConfigurationXLVask.vue + SessionUser.modules.minimax + i18n in all 5
locales).

**Followup (post-merge):** operator (jeppe) enters the MiniMax API key
in superuser XL Vask settings → I'll optimize/test/debug live XL Vask
usage logs against the new model.

---------

Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
This commit is contained in:
Jeppe B
2026-08-09 19:34:03 +02:00
committed by GitHub
co-authored by Truck Wash Agent
parent 6475f817c7
commit 2cf2538525
9 changed files with 363 additions and 6 deletions
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace classes;
require_once WD . '/modules/openAI/openAI_c.php';
require_once WD . '/modules/miniMax/miniMax_c.php';
use Exception;
use miniMax\miniMax_c;
/**
* Thrown when a MiniMax API request fails. Extends openai_request_exception so the
* autopilot's existing `catch (openai_request_exception $e)` blocks keep working
* when the model is swapped from OpenAI to MiniMax — no other code needs to change.
*/
class minimax_request_exception extends openai_request_exception
{
}
/**
* MiniMax M3 client.
*
* Uses the Anthropic-messages format (https://api.minimax.io/anthropic/v1/messages),
* which is the same endpoint OpenClaw's minimax-portal provider uses. The caller
* can pass `MiniMax-M3` (and any other model the operator has provisioned) via
* the `$model` argument.
*
* The response shape returned from jsonTask() matches openai::jsonTask() so callers
* (notably xlvask_automation_service) can switch providers with minimal plumbing.
*/
class minimax
{
public miniMax_c $config;
private string $api_url = 'https://api.minimax.io/anthropic/v1/messages';
protected string $model = 'MiniMax-M3';
protected string $temperature = '0.1';
protected string $max_tokens = '4096';
public function __construct()
{
$this->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;
}
}
@@ -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;
@@ -0,0 +1,29 @@
<?php
namespace miniMax\config;
use Exception;
use traits\module_config_variable;
class miniMax_api_key_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'miniMax',
'api_key',
'string',
false,
null,
'The secret API key for MiniMax (M3). Obtain from the MiniMax Portal dashboard; the operator can rotate or remove it from the superuser XL Vask module settings.',
'1',
true,
''
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace miniMax\config;
use Exception;
use traits\module_config_variable;
class miniMax_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'miniMax',
'enabled',
'bool',
true,
null,
'Whether the MiniMax integration is enabled for XL Vask autopilot and other AI-driven features.',
'1',
false,
'false'
);
}
}
@@ -0,0 +1,28 @@
<?php
namespace miniMax;
require_once WD . '/modules/miniMax/config/miniMax_enabled_c.php';
require_once WD . '/modules/miniMax/config/miniMax_api_key_c.php';
use miniMax\config\miniMax_api_key_c;
use miniMax\config\miniMax_enabled_c;
use traits\module_config_t;
class miniMax_c
{
use module_config_t;
public miniMax_enabled_c $enabled;
public miniMax_api_key_c $api_key;
public function __construct()
{
$this->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();
}
}
@@ -0,0 +1,29 @@
<?php
namespace xlvask\config;
use Exception;
use traits\module_config_variable;
class xlvask_minimax_integration_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'xlvask',
'minimax_integration_enabled',
'bool',
true,
null,
'Whether XL Vask automation may ask MiniMax (M3) for attachment or creation suggestions. Replaces the OpenAI integration.',
'0',
false,
'false'
);
}
}
@@ -5,6 +5,7 @@ require_once WD . '/modules/xlvask/config/xlvask_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_synchronization_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_openai_integration_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_username_c.php';
require_once WD . '/modules/xlvask/config/xlvask_password_c.php';
@@ -13,6 +14,7 @@ use traits\module_config_t;
use xlvask\config\xlvask_automatic_order_attachment_enabled_c;
use xlvask\config\xlvask_automatic_order_creation_enabled_c;
use xlvask\config\xlvask_enabled_c;
use xlvask\config\xlvask_minimax_integration_enabled_c;
use xlvask\config\xlvask_openai_integration_enabled_c;
use xlvask\config\xlvask_password_c;
use xlvask\config\xlvask_synchronization_enabled_c;
@@ -45,6 +47,10 @@ class xlvask_c
* @var xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled
*/
public xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled;
/**
* @var xlvask_minimax_integration_enabled_c $minimax_integration_enabled
*/
public xlvask_minimax_integration_enabled_c $minimax_integration_enabled;
/**
* @var xlvask_openai_integration_enabled_c $openai_integration_enabled
*/
@@ -73,6 +79,7 @@ class xlvask_c
xlvask_synchronization_enabled_c::class,
xlvask_automatic_order_attachment_enabled_c::class,
xlvask_automatic_order_creation_enabled_c::class,
xlvask_minimax_integration_enabled_c::class,
xlvask_openai_integration_enabled_c::class,
xlvask_username_c::class,
xlvask_password_c::class
@@ -81,6 +88,7 @@ class xlvask_c
$this->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();
@@ -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;
@@ -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,