Budget: after the token's permission was changed, read at once. */ public static function handleCheck(): never { if (!current_user_can('manage_options')) { wp_die(esc_html__('Sorry, you are not allowed to do that.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_account_usage_check'); self::refresh(true); wp_safe_redirect(\WordPress\CloudflareWorkersAiProvider\Admin\SettingsPage::settingsUrl('budget') . '#aipcf-budget-account'); exit; } /** * @param array $schedules * @return array */ public static function schedules($schedules): array { $schedules = is_array($schedules) ? $schedules : []; $schedules[self::SCHEDULE] = ['interval' => 15 * MINUTE_IN_SECONDS, 'display' => 'Every 15 minutes (AIPCF)']; return $schedules; } public static function enabled(): bool { return !empty(get_plugin_settings()['budget_account']) && get_account_id() !== '' && get_api_key() !== ''; } /** * Scheduled by the setting alone: a token from the Connectors screen is not readable this * early on init, so the run itself checks for credentials. */ public static function schedule(): void { $on = !empty(get_plugin_settings()['budget_account']); $next = wp_next_scheduled(self::HOOK); if ($on && $next === false) { wp_schedule_event(time() + MINUTE_IN_SECONDS, self::SCHEDULE, self::HOOK); } elseif (!$on && $next !== false) { wp_clear_scheduled_hook(self::HOOK); } } /** * What was last read: today's neurons, when, and why not ('' when it worked; 'permission' * when the token may not read analytics; otherwise a short error). * * @return array{day: string, neurons: float, at: int, error: string} */ public static function state(): array { $s = get_option(self::OPTION, []); $s = is_array($s) ? $s : []; return [ 'day' => (string) ($s['day'] ?? ''), 'neurons' => (float) ($s['neurons'] ?? 0), 'at' => (int) ($s['at'] ?? 0), 'error' => (string) ($s['error'] ?? ''), ]; } /** * The account's neurons today (UTC), or null when unknown or out of date. */ public static function today(): ?float { if (!self::enabled()) { return null; } $s = self::state(); return $s['day'] === UsageTracker::today() && $s['error'] === '' && $s['at'] > time() - self::FRESH ? $s['neurons'] : null; } /** * Reads today's figure from Cloudflare, then acts on it: past the daily budget, the budget * applies (the features pause or switch model), with an Activity entry and the email. */ public static function refresh(bool $now = false): void { if (!self::enabled()) { return; } $last = self::state(); if (!$now && $last['error'] === 'permission' && $last['at'] > time() - self::BACKOFF) { return; } $day = UsageTracker::today(); $result = self::fetch($day); $state = ['day' => $day, 'neurons' => is_float($result) ? $result : 0.0, 'at' => time(), 'error' => is_string($result) ? $result : '']; update_option(self::OPTION, $state, false); if (is_float($result)) { self::act($result, $last); } } /** * @return float|string The neurons, or the reason it could not be read. */ private static function fetch(string $day) { $response = wp_remote_post(self::ENDPOINT, [ 'timeout' => 15, 'headers' => ['Authorization' => 'Bearer ' . get_api_key(), 'Content-Type' => 'application/json'], 'body' => (string) wp_json_encode([ 'query' => self::QUERY, 'variables' => ['account' => get_account_id(), 'since' => $day . 'T00:00:00Z', 'until' => gmdate('Y-m-d\TH:i:s\Z')], ]), ]); if (is_wp_error($response)) { return mb_substr($response->get_error_message(), 0, 200); } $code = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); $errors = is_array($body) && !empty($body['errors']) ? implode(' ', array_map(static fn($e): string => is_array($e) ? (string) ($e['message'] ?? '') : '', (array) $body['errors'])) : ''; if ($code === 401 || $code === 403 || stripos($errors, 'not authorized') !== false || stripos($errors, 'permission') !== false || stripos($errors, 'authentication') !== false) { return 'permission'; } if ($code !== 200 || $errors !== '' || !is_array($body)) { return mb_substr($errors !== '' ? $errors : 'HTTP ' . $code, 0, 200); } $rows = $body['data']['viewer']['accounts'][0]['aiInferenceAdaptiveGroups'] ?? null; if (!is_array($rows)) { return 'unexpected response'; } $sum = 0.0; foreach ($rows as $row) { $sum += (float) ($row['sum']['totalNeurons'] ?? 0); } return $sum; } /** * The account went past the daily budget while the site's own use did not: say so once a day. * The budget itself already applies, since overBudget() counts the larger of the two. * * @param array{day: string, neurons: float, at: int, error: string} $last */ private static function act(float $neurons, array $last): void { if (!UsageTracker::budgetEnabled()) { return; } $budget = UsageTracker::budget(); $crossed = $neurons >= $budget; $before = $last['day'] === UsageTracker::today() && $last['error'] === '' && $last['neurons'] >= $budget; if (!$crossed || $before || UsageTracker::todayNeurons() >= $budget) { return; } ActivityLog::add([ 'actor' => 'plugin', 'tool' => 'budget', 'action' => 'account_budget_reached', 'status' => 'warning', 'message' => sprintf( /* translators: 1: the account's neurons today, 2: the daily budget, 3: this site's neurons today */ __('The whole Cloudflare account used %1$s neurons today, past the daily budget of %2$s (this site: %3$s). The budget setting now applies until midnight UTC.', 'aipcf-ai-provider-for-cloudflare'), number_format($neurons), number_format($budget), number_format(UsageTracker::todayNeurons()) ), ]); UsageTracker::notify($neurons, true); } /** * One line on the whole account's figure for the screens, or '' when it is not counted. */ public static function summary(): string { if (!self::enabled()) { return ''; } $s = self::state(); if ($s['error'] === 'permission') { return __('To read the whole account\'s use, give the API token the "Account Analytics: Read" permission.', 'aipcf-ai-provider-for-cloudflare'); } if ($s['at'] === 0) { return __('The first reading of the whole account comes within 15 minutes.', 'aipcf-ai-provider-for-cloudflare'); } if ($s['error'] !== '') { /* translators: %s: time since, e.g. "5 mins" */ return sprintf(__('Could not read the whole account\'s use %s ago. It tries again every 15 minutes.', 'aipcf-ai-provider-for-cloudflare'), human_time_diff($s['at'])); } $today = self::today(); if ($today === null) { return __('The whole account\'s figure is out of date. Check that background work runs, on Help.', 'aipcf-ai-provider-for-cloudflare'); } /* translators: 1: neurons, 2: time since, e.g. "5 mins" */ return sprintf(__('Whole Cloudflare account today: %1$s neurons, read %2$s ago.', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($today), human_time_diff($s['at'])); } }