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>
197 lines
7.6 KiB
PHP
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;
|
|
}
|
|
}
|