108 lines
6.7 KiB
PHP
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.');
|
|
}
|