Files
api/services/nginx/app/classes/minimax.php
T
Jeppe BandTruck Wash Agent 2cf2538525 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>
2026-08-09 19:34:03 +02:00

197 lines
7.6 KiB
PHP

<?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;
}
}