Raiju/includes/ai/request-ledger.php
2026-10-25 06:05:42 +03:00

108 lines
6.7 KiB
PHP

<?php
/** Reservations and usage share one transaction, so concurrent callers cannot oversubscribe. */
function raijuAiWithRequest(callable $run, ?string $id = null): array {
if (!empty($GLOBALS['raijuAiRequestId'])) return $run();
$id = $id ?: 'req_' . bin2hex(random_bytes(16));
$GLOBALS['raijuAiRequestId'] = $id;
$GLOBALS['raijuAiDispatched'] = false;
try {
$result = $run();
$result['requestId'] = $id;
return $result;
} catch (Throwable $error) {
raijuAiRequestPatch($id, [
'status' => !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.');
}