PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / ai / class-spend-guard.php

class-spend-guard.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/ai/class-spend-guard.php

297 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI spend ceiling and kill switch.
4 *
5 * @package ThinkRank\AI
6 * @since 2.9.0
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\AI;
12
13 use ThinkRank\Core\Settings;
14
15 if (!defined('ABSPATH')) {
16 exit;
17 }
18
19 /**
20 * Governs how much a site may spend on its own AI provider key.
21 *
22 * ThinkRank's only existing control is `max_requests_per_minute`, which is
23 * abuse prevention, not cost governance: it defaults to 0 (no throttle),
24 * so nothing intervenes however fast a site sends requests, and
25 * the setting was never rendered anywhere so no user could see or change it
26 * (#448). This class adds the two controls a user worried about credit drain
27 * actually needs — a daily ceiling they can see themselves approaching, and a
28 * single switch that stops outbound AI immediately.
29 *
30 * Both are enforced at the HTTP boundary, inside each provider client, rather
31 * than at the feature entry points. Feature code paths multiply (free has a
32 * dozen, Pro adds Auto AI, Internal Links and Refresh Radar, some of them
33 * cron-driven), and a ceiling that a new caller can forget to consult is not a
34 * ceiling. Every outbound provider request in both plugins goes through one of
35 * five request methods; those are the only places this has to be called.
36 *
37 * Neither control changes anything on an existing site until the user sets it:
38 * the ceiling defaults to 0, which means unlimited, and the switch defaults to
39 * off. The per-minute limiter from #42 is untouched and still runs.
40 *
41 * @since 2.9.0
42 */
43 final class Spend_Guard {
44
45 /**
46 * Option holding today's request count.
47 *
48 * Shape: ['date' => 'Y-m-d', 'count' => int]. An option rather than a
49 * transient because a spend ceiling that an object-cache eviction silently
50 * resets is not a ceiling. The write cost is irrelevant here: it happens
51 * once per outbound AI request, next to a multi-second HTTPS call to a paid
52 * provider, and never on a front-end pageview.
53 */
54 private const USAGE_OPTION = 'thinkrank_ai_daily_usage';
55
56 /**
57 * Blocked because the user turned AI off.
58 */
59 public const REASON_PAUSED = 'paused';
60
61 /**
62 * Blocked because today's ceiling is reached.
63 */
64 public const REASON_DAILY_LIMIT = 'daily_limit';
65
66 /**
67 * Refuse an outbound AI request when the user has turned AI off or used up
68 * today's allowance.
69 *
70 * Throws rather than returning false so a caller cannot proceed by ignoring
71 * the return value, and so the reason reaches the user: every AI path in
72 * both plugins already catches \Exception and surfaces its message, which
73 * is what turns this from a generic "AI request failed" into an explanation.
74 *
75 * @since 2.9.0
76 *
77 * @throws \RuntimeException When the request must not be sent.
78 * @return void
79 */
80 public static function guard(): void {
81 $reason = self::blocked_reason();
82
83 if ($reason === null) {
84 return;
85 }
86
87 throw new \RuntimeException(esc_html(self::message_for($reason)));
88 }
89
90 /**
91 * Why an outbound request would be refused right now, if it would be.
92 *
93 * @since 2.9.0
94 *
95 * @return string|null One of the REASON_* constants, or null when allowed.
96 */
97 public static function blocked_reason(): ?string {
98 if (self::is_paused()) {
99 return self::REASON_PAUSED;
100 }
101
102 $limit = self::daily_limit();
103
104 if ($limit > 0 && self::used_today() >= $limit) {
105 return self::REASON_DAILY_LIMIT;
106 }
107
108 return null;
109 }
110
111 /**
112 * Count an outbound AI request against today's allowance.
113 *
114 * Called immediately before the request is sent, not after it returns. A
115 * request that is dispatched and then times out may still have been billed
116 * by the provider, and a PHP process killed mid-call would otherwise lose
117 * the count entirely. For a spend ceiling, over-counting a failure is the
118 * safe direction to be wrong in.
119 *
120 * @since 2.9.0
121 *
122 * @return void
123 */
124 public static function record(): void {
125 $today = self::today();
126 $usage = self::usage();
127
128 $count = ($usage['date'] === $today) ? (int) $usage['count'] : 0;
129
130 update_option(
131 self::USAGE_OPTION,
132 [
133 'date' => $today,
134 'count' => $count + 1,
135 ],
136 false
137 );
138 }
139
140 /**
141 * Whether all outbound AI is switched off.
142 *
143 * @since 2.9.0
144 *
145 * @return bool
146 */
147 public static function is_paused(): bool {
148 return (bool) self::settings()->get('ai_paused', false);
149 }
150
151 /**
152 * Today's request ceiling. Zero means no ceiling.
153 *
154 * @since 2.9.0
155 *
156 * @return int
157 */
158 public static function daily_limit(): int {
159 return max(0, (int) self::settings()->get('ai_daily_request_limit', 0));
160 }
161
162 /**
163 * Requests already sent today.
164 *
165 * Reads as zero once the stored date is no longer today, which is what
166 * makes the window reset on the day boundary without a scheduled job.
167 *
168 * @since 2.9.0
169 *
170 * @return int
171 */
172 public static function used_today(): int {
173 $usage = self::usage();
174
175 return $usage['date'] === self::today() ? max(0, (int) $usage['count']) : 0;
176 }
177
178 /**
179 * The full picture, for the settings screen and the MCP abilities.
180 *
181 * @since 2.9.0
182 *
183 * @return array<string, mixed>
184 */
185 public static function status(): array {
186 $limit = self::daily_limit();
187 $used = self::used_today();
188 $reason = self::blocked_reason();
189
190 return [
191 'paused' => self::is_paused(),
192 'daily_limit' => $limit,
193 'used_today' => $used,
194 'remaining_today' => $limit > 0 ? max(0, $limit - $used) : null,
195 'per_minute_limit' => max(0, (int) self::settings()->get('max_requests_per_minute', 0)),
196 'resets_at' => self::next_reset(),
197 'blocked' => $reason !== null,
198 'blocked_reason' => $reason,
199 'message' => $reason !== null ? self::message_for($reason) : '',
200 ];
201 }
202
203 /**
204 * Wording shown to the user when a request is refused.
205 *
206 * @since 2.9.0
207 *
208 * @param string $reason One of the REASON_* constants.
209 * @return string
210 */
211 public static function message_for(string $reason): string {
212 if ($reason === self::REASON_PAUSED) {
213 return __('AI is paused in ThinkRank settings, so no requests are being sent to your AI provider. Turn AI back on to resume.', 'thinkrank');
214 }
215
216 return sprintf(
217 /* translators: %d: the configured daily AI request limit. */
218 __('ThinkRank has reached its daily AI limit of %d requests, so this request was not sent to your AI provider. The count resets at midnight, site time.', 'thinkrank'),
219 self::daily_limit()
220 );
221 }
222
223 /**
224 * Clear the stored counter.
225 *
226 * Exposed for the "reset now" control and for tests. Resetting does not
227 * refund anything at the provider; it only moves this site's own window.
228 *
229 * @since 2.9.0
230 *
231 * @return void
232 */
233 public static function reset_usage(): void {
234 delete_option(self::USAGE_OPTION);
235 }
236
237 /**
238 * Today's date in the site's timezone.
239 *
240 * Site timezone rather than UTC, because "resets at midnight" has to mean
241 * the midnight the user lives in, not the server's.
242 *
243 * @return string Y-m-d
244 */
245 private static function today(): string {
246 return (string) wp_date('Y-m-d');
247 }
248
249 /**
250 * When the current window ends, as a site-local ISO 8601 string.
251 *
252 * @return string
253 */
254 private static function next_reset(): string {
255 // Must be computed IN the site timezone, not merely formatted in it.
256 // strtotime('tomorrow', $ts) resolves "tomorrow" against PHP's default
257 // timezone — UTC under WordPress — so it landed on the next UTC
258 // midnight and wp_date() then rendered that same instant with the site
259 // offset bolted on: a UTC+6 site was told its allowance resets at
260 // 06:00, six hours after the counter in today() had already rolled
261 // over. current_datetime() is a DateTimeImmutable already in the site
262 // zone, so modify() resolves midnight there.
263 return (string) current_datetime()->modify('tomorrow midnight')->format('c');
264 }
265
266 /**
267 * Stored counter, normalised.
268 *
269 * @return array{date:string, count:int}
270 */
271 private static function usage(): array {
272 $stored = get_option(self::USAGE_OPTION, []);
273
274 if (!is_array($stored)) {
275 $stored = [];
276 }
277
278 return [
279 'date' => isset($stored['date']) ? (string) $stored['date'] : '',
280 'count' => isset($stored['count']) ? (int) $stored['count'] : 0,
281 ];
282 }
283
284 /**
285 * Settings singleton.
286 *
287 * Resolved per call rather than cached in a static property: Settings is
288 * already a singleton, so caching it here would buy nothing and would hold
289 * a stale instance across a test that replaces it.
290 *
291 * @return Settings
292 */
293 private static function settings(): Settings {
294 return Settings::instance();
295 }
296 }
297