!empty($GLOBALS['raijuAiDispatched']) ? 'uncertain' : 'released', 'error' => raijuAiUtf8Prefix($error->getMessage(), 500), 'updatedAt' => gmdate('c') ], true); throw $error; } finally { unset($GLOBALS['raijuAiRequestId'], $GLOBALS['raijuAiDispatched']); } } function raijuAiRequestPatch(string $id, array $patch, bool $onlyUnsettled = false): void { $ok = raijuJsonUpdate(RAIJU_AI_USAGE_PATH, static function (&$usage) use ($id, $patch, $onlyUnsettled) { if (!isset($usage['reservations'][$id])) return false; if ($onlyUnsettled && in_array($usage['reservations'][$id]['status'] ?? '', ['settled', 'released'], true)) return false; $usage['reservations'][$id] = array_replace($usage['reservations'][$id], $patch); }); // An absent reservation means validation failed before the reservation was created. if (!$ok && isset(raijuAiLoadUsage()['reservations'][$id])) { $current = raijuAiLoadUsage()['reservations'][$id]; if (!$onlyUnsettled || !in_array($current['status'] ?? '', ['settled', 'released'], true)) throw new RuntimeException('Could not persist AI request state.'); } } function raijuAiMarkDispatched(): void { $id = $GLOBALS['raijuAiRequestId'] ?? ''; if ($id === '') return; $GLOBALS['raijuAiDispatched'] = true; raijuAiRequestPatch($id, ['dispatchedAt' => gmdate('c')]); } function raijuAiReservedUsage(array $usage): array { foreach (($usage['reservations'] ?? []) as $entry) { if (!in_array($entry['status'] ?? '', ['reserved', 'uncertain'], true)) continue; foreach ([['days', substr($entry['createdAt'], 0, 10)], ['months', substr($entry['createdAt'], 0, 7)]] as [$group, $key]) { $usage[$group][$key] = array_replace(raijuAiEmptyUsageBucket(), $usage[$group][$key] ?? []); $usage[$group][$key]['requests']++; $usage[$group][$key][$entry['type'] === 'image' ? 'imageRequests' : 'textRequests']++; $usage[$group][$key]['estimatedCostCents'] = raijuAiAddCents($usage[$group][$key]['estimatedCostCents'], $entry['reservedCents']); } } return $usage; } function raijuAiReserve(array $settings, string $type, float $cost, string $id, array $meta = []): void { $ok = raijuJsonUpdate(RAIJU_AI_USAGE_PATH, static function (&$usage) use ($settings, $type, $cost, $id, $meta) { $previous = $usage['reservations'][$id] ?? null; if ($previous) { if ($previous['status'] === 'released') throw new RuntimeException('AI reservation was released; create a new request.'); if ($previous['type'] !== $type) throw new RuntimeException('AI request type mismatch.'); // A persistent image job resumes the original reservation and price estimate. $GLOBALS['raijuAiDispatched'] = !empty($previous['dispatchedAt']); return false; } raijuAiCheckLimits($settings, $type, $cost, raijuAiReservedUsage($usage)); $usage['reservations'][$id] = [ 'id' => $id, 'type' => $type, 'status' => 'reserved', 'reservedCents' => raijuAiRoundCents(max(0.0, $cost)), 'createdAt' => gmdate('c'), 'updatedAt' => gmdate('c'), 'provider' => $settings['provider'], 'baseUrl' => $settings['baseUrl'], 'model' => $type === 'image' ? (string)(($meta['model'] ?? '') ?: $settings['imageModel']) : (string)(($meta['model'] ?? '') ?: $settings['chatModel']), 'user' => (string)($_SESSION['admin_username'] ?? ''), 'tasks' => [] ]; if (isset($meta['service'])) $usage['reservations'][$id]['service'] = (string)$meta['service']; if ($type === 'image' && isset($meta['priced'])) $usage['reservations'][$id]['priced'] = (bool)$meta['priced']; }, ['days' => [], 'months' => [], 'reservations' => []]); if (!$ok && !isset(raijuAiLoadUsage()['reservations'][$id])) throw new RuntimeException('Could not reserve AI budget.'); } function raijuAiReservationSummary(array $usage): array { $total = 0.0; $count = 0; $unresolved = []; foreach (($usage['reservations'] ?? []) as $entry) { if (!in_array($entry['status'] ?? '', ['reserved', 'uncertain'], true)) continue; $count++; if (substr($entry['createdAt'], 0, 7) === gmdate('Y-m')) $total = raijuAiAddCents($total, $entry['reservedCents']); $unresolved[] = $entry; } return ['reservedCents' => $total, 'pendingRequests' => $count, 'unresolved' => $unresolved]; } function raijuAiResolveReservation(string $id, string $resolution): void { if (!in_array($resolution, ['charged', 'released'], true)) throw new RuntimeException('Invalid resolution'); $ok = raijuJsonUpdate(RAIJU_AI_USAGE_PATH, static function (&$usage) use ($id, $resolution) { $entry = $usage['reservations'][$id] ?? null; if (!$entry || !in_array($entry['status'] ?? '', ['reserved', 'uncertain'], true)) throw new RuntimeException('Request is already resolved.'); // Never release a possibly live worker's reservation. if (strtotime($entry['updatedAt'] ?? $entry['createdAt']) > time() - 900) throw new RuntimeException('Wait until the request has stopped before resolving it.'); if ($resolution === 'charged') { foreach ([['days', substr($entry['createdAt'], 0, 10)], ['months', substr($entry['createdAt'], 0, 7)]] as [$group, $key]) { $usage[$group][$key] = array_replace(raijuAiEmptyUsageBucket(), $usage[$group][$key] ?? []); $usage[$group][$key]['requests']++; $usage[$group][$key][$entry['type'] === 'image' ? 'imageRequests' : 'textRequests']++; $usage[$group][$key]['estimatedCostCents'] = raijuAiAddCents($usage[$group][$key]['estimatedCostCents'], $entry['reservedCents']); } } $usage['reservations'][$id]['status'] = $resolution === 'charged' ? 'settled' : 'released'; $usage['reservations'][$id]['resolution'] = $resolution; $usage['reservations'][$id]['resolvedBy'] = (string)($_SESSION['admin_username'] ?? ''); $usage['reservations'][$id]['updatedAt'] = gmdate('c'); }); if (!$ok) throw new RuntimeException('Could not resolve AI request.'); }