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:
co-authored by
Truck Wash Agent
parent
6475f817c7
commit
2cf2538525
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user