From 23fc410d25af52a8e28967aedfcaad6c01071f28 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 4 Aug 2026 17:46:15 +0200 Subject: [PATCH] xlvask-autopilot (#347) Co-authored-by: Jeppe Bundgaard --- services/nginx/app/classes/openai.php | 15 +- .../app/classes/xlvask_automation_service.php | 140 ++++++++++++++++++ .../app/classes/xlvask_autopilot_service.php | 106 ++++++++++++- .../xlvask_usage_logs_schema_bootstrap.php | 16 ++ .../nginx/app/routes/xlvaskUsageLogsRoute.php | 15 +- .../XLVask/XLVaskAutomationServiceTest.php | 15 +- .../XLVask/XLVaskUsageRouteContractTest.php | 3 + 7 files changed, 304 insertions(+), 6 deletions(-) diff --git a/services/nginx/app/classes/openai.php b/services/nginx/app/classes/openai.php index 728dcb9a..40816a0c 100644 --- a/services/nginx/app/classes/openai.php +++ b/services/nginx/app/classes/openai.php @@ -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 diff --git a/services/nginx/app/classes/xlvask_automation_service.php b/services/nginx/app/classes/xlvask_automation_service.php index fdb4bc14..b8008ea9 100644 --- a/services/nginx/app/classes/xlvask_automation_service.php +++ b/services/nginx/app/classes/xlvask_automation_service.php @@ -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 { diff --git a/services/nginx/app/classes/xlvask_autopilot_service.php b/services/nginx/app/classes/xlvask_autopilot_service.php index f812238e..f41f7b7f 100644 --- a/services/nginx/app/classes/xlvask_autopilot_service.php +++ b/services/nginx/app/classes/xlvask_autopilot_service.php @@ -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) { diff --git a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php index 6b79bd04..f285030b 100644 --- a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php +++ b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php @@ -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'); diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index 90d7f210..1472bdbf 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -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); } diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php index ebf7e6c0..6e9bae38 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php @@ -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 { diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php index a796e424..2025bdfe 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php @@ -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);'); });