xlvask-autopilot (#347)

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
This commit is contained in:
Jeppe B
2026-08-04 17:46:15 +02:00
committed by GitHub
co-authored by Jeppe Bundgaard
parent db9f589bf7
commit 23fc410d25
7 changed files with 304 additions and 6 deletions
+14 -1
View File
@@ -133,7 +133,20 @@ class openai implements openai_i
if ($resolvedModel === '') {
throw new openai_request_exception('OpenAI response omitted the resolved model.', false);
}
return [...$decoded, '_openai_response_model' => $resolvedModel];
$usage = (array)($response['usage'] ?? []);
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
$totalTokens = max(0, (int)($usage['total_tokens'] ?? ($inputTokens + $outputTokens)));
return [
...$decoded,
'_openai_response_model' => $resolvedModel,
'_openai_usage' => [
'input_tokens' => $inputTokens,
'output_tokens' => $outputTokens,
'total_tokens' => $totalTokens,
'service_tier' => (string)($response['service_tier'] ?? ''),
],
];
}
protected function getLPRSchema(): array
@@ -40,8 +40,36 @@ class xlvask_automation_service
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.';
private const OPENAI_ATTACH_MAX_DISTANCE_HOURS = 6;
private const AI_TIMELINES = ['priority', 'standard', 'economy'];
private const DEFAULT_AI_TIMELINE = 'standard';
private const DEFAULT_AI_BATCH_SIZE_BY_TIMELINE = [
'priority' => 500,
'standard' => 150,
'economy' => 50,
];
private const DEFAULT_AI_INPUT_USD_PER_1M = 0.50;
private const DEFAULT_AI_OUTPUT_USD_PER_1M = 2.00;
private ?int $runId = null;
private bool $readOnlyEvaluation = false;
/** @var array{timeline:string,batch_size:int,max_cost_usd:?float,input_usd_per_1m:float,output_usd_per_1m:float} */
private array $aiPolicy = [
'timeline' => self::DEFAULT_AI_TIMELINE,
'batch_size' => self::DEFAULT_AI_BATCH_SIZE_BY_TIMELINE[self::DEFAULT_AI_TIMELINE],
'max_cost_usd' => null,
'input_usd_per_1m' => self::DEFAULT_AI_INPUT_USD_PER_1M,
'output_usd_per_1m' => self::DEFAULT_AI_OUTPUT_USD_PER_1M,
];
/** @var array{requests:int,cache_hits:int,input_tokens:int,output_tokens:int,total_tokens:int,estimated_cost_usd:float,budget_exhausted:bool,stop_reason:?string} */
private array $aiUsage = [
'requests' => 0,
'cache_hits' => 0,
'input_tokens' => 0,
'output_tokens' => 0,
'total_tokens' => 0,
'estimated_cost_usd' => 0.0,
'budget_exhausted' => false,
'stop_reason' => null,
];
public function __construct()
{
@@ -60,6 +88,31 @@ class xlvask_automation_service
return $this;
}
public function setAiExecutionPolicy(array $policy): self
{
$timeline = strtolower(trim((string)($policy['timeline'] ?? self::DEFAULT_AI_TIMELINE)));
if (!in_array($timeline, self::AI_TIMELINES, true)) {
$timeline = self::DEFAULT_AI_TIMELINE;
}
$defaultBatch = self::DEFAULT_AI_BATCH_SIZE_BY_TIMELINE[$timeline];
$batchSize = isset($policy['batch_size']) ? (int)$policy['batch_size'] : $defaultBatch;
$batchSize = max(1, min(500, $batchSize));
$maxCost = isset($policy['max_cost_usd']) && $policy['max_cost_usd'] !== null
? max(0.0, (float)$policy['max_cost_usd'])
: null;
$inputRate = isset($policy['input_usd_per_1m']) ? max(0.0, (float)$policy['input_usd_per_1m']) : self::DEFAULT_AI_INPUT_USD_PER_1M;
$outputRate = isset($policy['output_usd_per_1m']) ? max(0.0, (float)$policy['output_usd_per_1m']) : self::DEFAULT_AI_OUTPUT_USD_PER_1M;
$this->aiPolicy = [
'timeline' => $timeline,
'batch_size' => $batchSize,
'max_cost_usd' => $maxCost,
'input_usd_per_1m' => $inputRate,
'output_usd_per_1m' => $outputRate,
];
$this->resetAiUsage();
return $this;
}
public static function openAiFailureRequiresDurableRetry(openai_request_exception $exception, ?int $runId): bool
{
return $exception->retryable && $runId !== null && $runId > 0;
@@ -416,6 +469,7 @@ class xlvask_automation_service
$consecutiveFailures = 0;
$circuitBreaker = null;
$this->setReadOnlyEvaluation($readOnlyEvaluation);
$this->resetAiUsage();
foreach ($rows as $row) {
if ($heartbeat !== null) {
$heartbeat();
@@ -475,6 +529,7 @@ class xlvask_automation_service
'automatic_links' => $autoLinks,
'automatic_creations' => $autoCreates,
'circuit_breaker' => $circuitBreaker,
'ai_usage' => $this->aiUsage,
];
}
@@ -983,13 +1038,21 @@ class xlvask_automation_service
$cacheKey = self::openAiCacheKeyForAutomation($schemaName, $prompt, $payload, $schema, $temperature);
$result = $this->loadOpenAiCacheResult($cacheKey);
$fromCache = $result !== null;
if ($fromCache) {
$this->aiUsage['cache_hits']++;
}
if ($result === null) {
if ($this->readOnlyEvaluation) {
return null;
}
if ($this->openAiBudgetReached()) {
return null;
}
$openai = new openai();
$result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature, self::PLANNER_MODEL);
$result = $this->sanitizeOpenAiResult($result, $context);
$this->recordOpenAiUsage((array)($result['usage'] ?? []));
if (!$this->readOnlyEvaluation) {
$this->persistOpenAiCacheResult($cacheKey, $schemaName, $payload, $schema, $prompt, $temperature, $result);
}
@@ -1037,6 +1100,7 @@ class xlvask_automation_service
'risk_flags' => array_values(array_filter(array_map('strval', (array)($result['risk_flags'] ?? [])))),
'contradictions' => array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))),
'plan_steps' => array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')),
'usage' => (array)($result['usage'] ?? []),
];
} catch (openai_request_exception $exception) {
// Retryable provider/transport failures must reach the durable run
@@ -1084,6 +1148,12 @@ class xlvask_automation_service
'evidence' => $stringList($result['evidence'] ?? []),
'contradictions' => $stringList($result['contradictions'] ?? []),
'plan_steps' => $planSteps,
'usage' => [
'input_tokens' => max(0, (int)($result['_openai_usage']['input_tokens'] ?? 0)),
'output_tokens' => max(0, (int)($result['_openai_usage']['output_tokens'] ?? 0)),
'total_tokens' => max(0, (int)($result['_openai_usage']['total_tokens'] ?? 0)),
'service_tier' => (string)($result['_openai_usage']['service_tier'] ?? ''),
],
];
}
@@ -2660,6 +2730,76 @@ class xlvask_automation_service
return self::normalizeRegistrationForAutomation($registration);
}
private function resetAiUsage(): void
{
$this->aiUsage = [
'requests' => 0,
'cache_hits' => 0,
'input_tokens' => 0,
'output_tokens' => 0,
'total_tokens' => 0,
'estimated_cost_usd' => 0.0,
'budget_exhausted' => false,
'stop_reason' => null,
];
}
private function openAiBudgetReached(): bool
{
if ($this->aiUsage['budget_exhausted']) {
return true;
}
if ($this->aiUsage['requests'] >= (int)$this->aiPolicy['batch_size']) {
$this->aiUsage['budget_exhausted'] = true;
$this->aiUsage['stop_reason'] = 'ai_batch_size_reached';
return true;
}
$maxCost = $this->aiPolicy['max_cost_usd'];
if ($maxCost !== null && $this->aiUsage['estimated_cost_usd'] >= $maxCost) {
$this->aiUsage['budget_exhausted'] = true;
$this->aiUsage['stop_reason'] = 'ai_cost_limit_reached';
return true;
}
return false;
}
private function recordOpenAiUsage(array $usage): void
{
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
$totalTokens = max(0, (int)($usage['total_tokens'] ?? ($inputTokens + $outputTokens)));
$estimatedCostUsd = (($inputTokens * (float)$this->aiPolicy['input_usd_per_1m'])
+ ($outputTokens * (float)$this->aiPolicy['output_usd_per_1m'])) / 1000000;
$this->aiUsage['requests']++;
$this->aiUsage['input_tokens'] += $inputTokens;
$this->aiUsage['output_tokens'] += $outputTokens;
$this->aiUsage['total_tokens'] += $totalTokens;
$this->aiUsage['estimated_cost_usd'] = round($this->aiUsage['estimated_cost_usd'] + $estimatedCostUsd, 6);
try {
$usageService = new module_usage_service();
$usageService->recordUsage('openai', 'api_calls', 1, [
'run_id' => $this->runId,
'timeline' => $this->aiPolicy['timeline'],
]);
if ($totalTokens > 0) {
$usageService->recordUsage('openai', 'total_tokens', $totalTokens, [
'run_id' => $this->runId,
'timeline' => $this->aiPolicy['timeline'],
]);
}
} catch (\Throwable) {
// Usage metrics are observability controls and must not break automation flow.
}
$maxCost = $this->aiPolicy['max_cost_usd'];
if ($maxCost !== null && $this->aiUsage['estimated_cost_usd'] >= $maxCost) {
$this->aiUsage['budget_exhausted'] = true;
$this->aiUsage['stop_reason'] = 'ai_cost_limit_reached';
}
}
private function isOpenAiEnabled(): bool
{
try {
@@ -19,6 +19,7 @@ class xlvask_autopilot_service
{
public const POLICY_VERSION = xlvask_automation_service::POLICY_VERSION;
private const PREVIEW_TTL_SECONDS = 900;
private const AI_TIMELINES = ['priority', 'standard', 'economy'];
public static function modeCapabilities(string $mode): array
{
@@ -106,6 +107,11 @@ class xlvask_autopilot_service
}
}
$forceRefetch = filter_var($input['forceRefetch'] ?? false, FILTER_VALIDATE_BOOL);
$aiTimeline = $this->normalizeAiTimeline($input['aiTimeline'] ?? null);
$aiBatchSize = $this->normalizeAiBatchSize($input['aiBatchSize'] ?? null, $aiTimeline);
$aiMaxCostUsd = $this->normalizeAiMaxCostUsd($input['aiMaxCostUsd'] ?? null);
$aiInputUsdPer1m = $this->normalizeAiRate($input['aiInputUsdPer1mUsd'] ?? null, 0.5);
$aiOutputUsdPer1m = $this->normalizeAiRate($input['aiOutputUsdPer1mUsd'] ?? null, 2.0);
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
if ($allowedHallIds === []) {
throw new Exception('No XL Vask hall scope is available for this user.');
@@ -134,6 +140,11 @@ class xlvask_autopilot_service
'limit' => $requestedLimit,
'force_refetch' => $forceRefetch,
'scope_hall_ids' => $allowedHallIds,
'ai_timeline' => $aiTimeline,
'ai_batch_size' => $aiBatchSize,
'ai_max_cost_usd' => $aiMaxCostUsd,
'ai_input_usd_per_1m' => $aiInputUsdPer1m,
'ai_output_usd_per_1m' => $aiOutputUsdPer1m,
]);
$idsJson = $db->escape_string(json_encode($ids, JSON_UNESCAPED_SLASHES) ?: '[]');
$scopeJson = $db->escape_string(json_encode($allowedHallIds, JSON_UNESCAPED_SLASHES) ?: '[]');
@@ -142,13 +153,19 @@ class xlvask_autopilot_service
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
$keySql = $db->escape_string($key);
$modeSql = $db->escape_string($mode);
$aiTimelineSql = $db->escape_string($aiTimeline);
$aiMaxCostSql = $aiMaxCostUsd === null ? 'NULL' : (string)round($aiMaxCostUsd, 4);
$aiInputRateSql = (string)round($aiInputUsdPer1m, 4);
$aiOutputRateSql = (string)round($aiOutputUsdPer1m, 4);
if ($db->query(
"INSERT INTO xlvask_autopilot_runs
(idempotency_key, request_hash, mode, date_from, date_to, force_refetch, requested_ids_json,
requested_limit, scope_hall_ids_json, created_by)
requested_limit, scope_hall_ids_json, ai_timeline, ai_batch_size, ai_max_cost_usd,
ai_input_usd_per_1m_usd, ai_output_usd_per_1m_usd, created_by)
VALUES ('{$keySql}', '{$requestHash}', '{$modeSql}', {$dateFromSql}, {$dateToSql}, " . ($forceRefetch ? '1' : '0') . ",
'{$idsJson}', {$requestedLimit}, '{$scopeJson}', {$actorSql})
'{$idsJson}', {$requestedLimit}, '{$scopeJson}', '{$aiTimelineSql}', {$aiBatchSize}, {$aiMaxCostSql},
{$aiInputRateSql}, {$aiOutputRateSql}, {$actorSql})
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)"
) === false) {
throw new Exception('The XL Vask autopilot run could not be queued atomically.');
@@ -258,6 +275,14 @@ class xlvask_autopilot_service
$renewLease();
$automation = new xlvask_automation_service();
$automation->setRunContext($runId);
$automation->setAiExecutionPolicy([
'timeline' => (string)($run['ai_timeline'] ?? 'standard'),
'batch_size' => (int)($run['ai_batch_size'] ?? 150),
'max_cost_usd' => isset($run['ai_max_cost_usd']) && $run['ai_max_cost_usd'] !== null
? (float)$run['ai_max_cost_usd'] : null,
'input_usd_per_1m' => (float)($run['ai_input_usd_per_1m_usd'] ?? 0.5),
'output_usd_per_1m' => (float)($run['ai_output_usd_per_1m_usd'] ?? 2.0),
]);
$allowExecute = $capabilities['execute_actions'];
$results = $automation->runPending(
$dateFrom,
@@ -274,6 +299,8 @@ class xlvask_autopilot_service
$this->persistRunItems($runId, $results['results'] ?? []);
$summary = $this->getSummary($dateFrom, $dateTo, $scopeHallIds);
$summary['import'] = $importSummary;
$summary['ai_usage'] = (array)($results['ai_usage'] ?? []);
$summary['ai_timeline'] = (string)($run['ai_timeline'] ?? 'standard');
$circuitBreaker = $results['circuit_breaker'] ?? null;
$warning = $circuitBreaker === null ? null : 'Autopilot stopped early: ' . (string)$circuitBreaker;
$summary['circuit_breaker'] = $circuitBreaker;
@@ -289,9 +316,21 @@ class xlvask_autopilot_service
$status = $circuitBreaker === null ? 'completed' : 'completed_with_warnings';
$phase = $circuitBreaker === null ? 'completed' : 'circuit_breaker';
$warningSql = $warning === null ? 'NULL' : "'" . $db->escape_string($warning) . "'";
$aiUsage = (array)($results['ai_usage'] ?? []);
$aiRequests = (int)($aiUsage['requests'] ?? 0);
$aiCacheHits = (int)($aiUsage['cache_hits'] ?? 0);
$aiInputTokens = (int)($aiUsage['input_tokens'] ?? 0);
$aiOutputTokens = (int)($aiUsage['output_tokens'] ?? 0);
$aiTotalTokens = (int)($aiUsage['total_tokens'] ?? 0);
$aiEstimatedCost = round((float)($aiUsage['estimated_cost_usd'] ?? 0), 6);
$aiBudgetExhausted = !empty($aiUsage['budget_exhausted']) ? 1 : 0;
$db->query(
"UPDATE xlvask_autopilot_runs SET status = '{$status}', phase = '{$phase}', processed = {$processed},
total = {$total}, summary_json = '{$summaryJson}', warning = {$warningSql}, finished_at = NOW()
total = {$total}, summary_json = '{$summaryJson}', warning = {$warningSql},
ai_requests = {$aiRequests}, ai_cache_hits = {$aiCacheHits},
ai_input_tokens = {$aiInputTokens}, ai_output_tokens = {$aiOutputTokens},
ai_total_tokens = {$aiTotalTokens}, ai_estimated_cost_usd = {$aiEstimatedCost},
ai_budget_exhausted = {$aiBudgetExhausted}, finished_at = NOW()
WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'"
);
if ($db->conn()->affected_rows !== 1) {
@@ -1082,6 +1121,21 @@ class xlvask_autopilot_service
'attempt_count' => (int)($row['attempt_count'] ?? 0),
'max_attempts' => (int)($row['max_attempts'] ?? 3),
'next_attempt_at' => $row['next_attempt_at'] ?: null,
'ai' => [
'timeline' => (string)($row['ai_timeline'] ?? 'standard'),
'batch_size' => (int)($row['ai_batch_size'] ?? 150),
'max_cost_usd' => isset($row['ai_max_cost_usd']) && $row['ai_max_cost_usd'] !== null
? (float)$row['ai_max_cost_usd'] : null,
'input_usd_per_1m_usd' => (float)($row['ai_input_usd_per_1m_usd'] ?? 0.5),
'output_usd_per_1m_usd' => (float)($row['ai_output_usd_per_1m_usd'] ?? 2.0),
'requests' => (int)($row['ai_requests'] ?? 0),
'cache_hits' => (int)($row['ai_cache_hits'] ?? 0),
'input_tokens' => (int)($row['ai_input_tokens'] ?? 0),
'output_tokens' => (int)($row['ai_output_tokens'] ?? 0),
'total_tokens' => (int)($row['ai_total_tokens'] ?? 0),
'estimated_cost_usd' => (float)($row['ai_estimated_cost_usd'] ?? 0.0),
'budget_exhausted' => (bool)($row['ai_budget_exhausted'] ?? false),
],
];
}
@@ -1119,6 +1173,52 @@ class xlvask_autopilot_service
return $value;
}
private function normalizeAiTimeline(mixed $value): string
{
$timeline = strtolower(trim((string)($value ?? '')));
if ($timeline === '') {
return 'standard';
}
if (!in_array($timeline, self::AI_TIMELINES, true)) {
throw new Exception('Invalid aiTimeline.');
}
return $timeline;
}
private function normalizeAiBatchSize(mixed $value, string $timeline): int
{
$defaults = ['priority' => 500, 'standard' => 150, 'economy' => 50];
if ($value === null || trim((string)$value) === '') {
return $defaults[$timeline] ?? 150;
}
if (!is_numeric($value)) {
throw new Exception('Invalid aiBatchSize.');
}
return max(1, min(500, (int)$value));
}
private function normalizeAiMaxCostUsd(mixed $value): ?float
{
if ($value === null || trim((string)$value) === '') {
return null;
}
if (!is_numeric($value)) {
throw new Exception('Invalid aiMaxCostUsd.');
}
return max(0.0, round((float)$value, 4));
}
private function normalizeAiRate(mixed $value, float $default): float
{
if ($value === null || trim((string)$value) === '') {
return $default;
}
if (!is_numeric($value)) {
throw new Exception('Invalid AI pricing input.');
}
return max(0.0, round((float)$value, 4));
}
private function wilsonLowerBound(int $successes, int $total): float
{
if ($total < 1) {
@@ -130,6 +130,10 @@ class xlvask_usage_logs_schema_bootstrap
'processed', 'total', 'summary_json', 'warning', 'error', 'lease_token',
'lease_expires_at', 'attempt_count', 'max_attempts', 'next_attempt_at', 'created_by',
'created_at', 'updated_at', 'started_at', 'finished_at', 'active_execute_slot',
'ai_timeline', 'ai_batch_size', 'ai_max_cost_usd',
'ai_input_usd_per_1m_usd', 'ai_output_usd_per_1m_usd',
'ai_requests', 'ai_cache_hits', 'ai_input_tokens', 'ai_output_tokens',
'ai_total_tokens', 'ai_estimated_cost_usd', 'ai_budget_exhausted',
],
'xlvask_autopilot_run_items' => [
'run_id', 'usage_log_id', 'wash_id', 'import_state', 'resolution_state', 'certainty',
@@ -321,6 +325,18 @@ class xlvask_usage_logs_schema_bootstrap
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'scope_hall_ids_json', "LONGTEXT NULL AFTER requested_ids_json");
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'requested_limit', 'INT NOT NULL DEFAULT 500 AFTER requested_ids_json');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'request_hash', "CHAR(64) NOT NULL DEFAULT '' AFTER requested_limit");
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_timeline', "VARCHAR(16) NOT NULL DEFAULT 'standard' AFTER scope_hall_ids_json");
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_batch_size', 'INT NOT NULL DEFAULT 150 AFTER ai_timeline');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_max_cost_usd', 'DECIMAL(12,4) NULL AFTER ai_batch_size');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 0.5000 AFTER ai_max_cost_usd');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 2.0000 AFTER ai_input_usd_per_1m_usd');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_requests', 'INT NOT NULL DEFAULT 0 AFTER total');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_cache_hits', 'INT NOT NULL DEFAULT 0 AFTER ai_requests');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_cache_hits');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_input_tokens');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_total_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_output_tokens');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_estimated_cost_usd', 'DECIMAL(14,6) NOT NULL DEFAULT 0.000000 AFTER ai_total_tokens');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_budget_exhausted', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER ai_estimated_cost_usd');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts');
@@ -197,7 +197,20 @@ class xlvaskUsageLogsRoute
}
$input = [];
foreach (['ids', 'dateFrom', 'dateTo', 'limit', 'forceRefetch', 'mode', 'idempotency_key'] as $key) {
foreach ([
'ids',
'dateFrom',
'dateTo',
'limit',
'forceRefetch',
'mode',
'idempotency_key',
'aiTimeline',
'aiBatchSize',
'aiMaxCostUsd',
'aiInputUsdPer1mUsd',
'aiOutputUsdPer1mUsd',
] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
@@ -249,6 +249,7 @@ it('uses the existing OpenAI module with strict no-retention planner settings',
->toContain("'role' => 'user'")
->toContain("if (\$status !== 'completed')")
->toContain("=== 'refusal'")
->toContain("'_openai_usage' => [")
->and($automation)->toContain("private const PLANNER_MODEL = 'gpt-5.6-sol'")
->toContain('candidate_order_id')
->not->toContain('opaque_context_id')
@@ -273,6 +274,9 @@ it('uses a dedicated queued XL Vask autopilot service with scoped durable runs',
->toContain('public function getRun(')
->toContain('ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)')
->toContain('scope_hall_ids_json')
->toContain('ai_timeline')
->toContain('ai_batch_size')
->toContain('ai_max_cost_usd')
->toContain('lease_expires_at')
->toContain('attempt_count')
->toContain('next_attempt_at')
@@ -394,7 +398,16 @@ it('accepts only completed structured OpenAI responses and records the resolved
]],
]],
]);
expect($parsed)->toBe(['action' => 'none', '_openai_response_model' => 'gpt-5.6-sol']);
expect($parsed)->toBe([
'action' => 'none',
'_openai_response_model' => 'gpt-5.6-sol',
'_openai_usage' => [
'input_tokens' => 0,
'output_tokens' => 0,
'total_tokens' => 0,
'service_tier' => '',
],
]);
});
it('fails incomplete and refusal OpenAI responses closed', function (): void {
@@ -106,6 +106,9 @@ it('exposes additive XL Vask autopilot run and summary routes', function (): voi
->toContain('(new xlvask_autopilot_service())->createRun(')
->toContain('(new xlvask_autopilot_service())->getRun(')
->toContain('$this->allowedHallIdsForUser($user)')
->toContain("'aiTimeline'")
->toContain("'aiBatchSize'")
->toContain("'aiMaxCostUsd'")
->toContain('], 202);');
});