PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / ai / authentication / application / ai-request-sender.php

ai-request-sender.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.2, at src/ai/authentication/application/ai-request-sender.php

280 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4
5 namespace Yoast\WP\SEO\AI\Authentication\Application;
6
7 use WP_User;
8 use Yoast\WP\SEO\AI\Authentication\Domain\Exceptions\Auth_Strategy_Unavailable_Exception;
9 use Yoast\WP\SEO\AI\Content_Planner\Domain\Content_Outline_Parameters;
10 use Yoast\WP\SEO\AI\Content_Planner\Domain\Content_Suggestion_Parameters;
11 use Yoast\WP\SEO\AI\Generator\Domain\Suggestions_Parameters;
12 use Yoast\WP\SEO\AI\Generator\Domain\Usage_Parameters;
13 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Remote_Request_Exception;
14 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
15 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception;
16 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request;
17 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Response;
18 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
19 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
20 use YoastSEO_Vendor\Psr\Log\NullLogger;
21
22 /**
23 * Sends an authenticated AI request using a primary strategy, with an optional fallback.
24 */
25 class AI_Request_Sender implements LoggerAwareInterface {
26
27 use LoggerAwareTrait;
28
29 /**
30 * The primary strategy.
31 *
32 * @var Auth_Strategy_Interface
33 */
34 private $primary;
35
36 /**
37 * The fallback strategy, or null when no fallback should be tried on persistent failure.
38 *
39 * @var Auth_Strategy_Interface|null
40 */
41 private $fallback;
42
43 /**
44 * Constructor.
45 *
46 * @param Auth_Strategy_Interface $primary The primary strategy.
47 * @param Auth_Strategy_Interface|null $fallback The fallback strategy, or null for no fallback.
48 */
49 public function __construct( Auth_Strategy_Interface $primary, ?Auth_Strategy_Interface $fallback = null ) {
50 $this->primary = $primary;
51 $this->fallback = $fallback;
52 $this->logger = new NullLogger();
53 }
54
55 // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing -- Strategies throw typed exceptions that propagate out.
56
57 /**
58 * Requests a content outline from the AI service.
59 *
60 * @param Content_Outline_Parameters $parameters The outline parameters.
61 *
62 * @return Response The parsed response.
63 */
64 public function get_content_outline_suggestions( Content_Outline_Parameters $parameters ): Response {
65 $request = new Request(
66 '/content-planner/next-post-outline',
67 [
68 'subject' => [
69 'language' => $parameters->get_language(),
70 'content' => $parameters->get_content(),
71 ],
72 ],
73 [ 'X-Yst-Cohort' => $parameters->get_editor() ],
74 );
75
76 return $this->send( $request, $parameters->get_user() );
77 }
78
79 /**
80 * Requests next-post content suggestions from the AI service.
81 *
82 * @param Content_Suggestion_Parameters $parameters The suggestion parameters.
83 *
84 * @return Response The parsed response.
85 */
86 public function get_content_suggestions( Content_Suggestion_Parameters $parameters ): Response {
87 $request = new Request(
88 '/content-planner/next-post-suggestions',
89 [
90 'subject' => [
91 'language' => $parameters->get_language(),
92 'content' => $parameters->get_content(),
93 ],
94 ],
95 [ 'X-Yst-Cohort' => $parameters->get_editor() ],
96 );
97
98 return $this->send( $request, $parameters->get_user() );
99 }
100
101 /**
102 * Requests suggestions for the given suggestion type.
103 *
104 * @param Suggestions_Parameters $parameters The suggestions parameters.
105 *
106 * @return Response The parsed response.
107 */
108 public function get_suggestions( Suggestions_Parameters $parameters ): Response {
109 $user = $parameters->get_user();
110 $request = new Request(
111 '/openai/suggestions/' . $parameters->get_suggestion_type(),
112 [
113 'service' => 'openai',
114 'user_id' => (string) $user->ID,
115 'subject' => [
116 'content' => $parameters->get_prompt_content(),
117 'focus_keyphrase' => $parameters->get_focus_keyphrase(),
118 'language' => $parameters->get_language(),
119 'platform' => $parameters->get_platform(),
120 ],
121 ],
122 [ 'X-Yst-Cohort' => $parameters->get_editor() ],
123 );
124
125 return $this->send( $request, $user );
126 }
127
128 /**
129 * Requests the user's current usage.
130 *
131 * @param Usage_Parameters $parameters The usage parameters.
132 *
133 * @return Response The parsed response.
134 */
135 public function get_usage( Usage_Parameters $parameters ): Response {
136 $action_path = $parameters->is_free() ? '/usage/free-usages' : '/usage/' . $parameters->get_period();
137 $request = new Request( $action_path, [], [], Request::METHOD_GET );
138
139 return $this->send( $request, $parameters->get_user() );
140 }
141
142 /**
143 * Records the user's consent on the AI service.
144 *
145 * The strategy identifies the WP user to the service (the OAuth path injects `user_id` into the
146 * POST body), so no body is built here.
147 *
148 * @param WP_User $user The WP user granting consent.
149 *
150 * @return Response The parsed response.
151 */
152 public function grant_consent( WP_User $user ): Response {
153 return $this->send( new Request( '/user/consent', [], [], Request::METHOD_POST ), $user );
154 }
155
156 /**
157 * Revokes the user's consent on the AI service via `DELETE /user/consent`.
158 *
159 * The strategy identifies the WP user to the service (the OAuth path appends the `user_id`
160 * query parameter to the DELETE), so no body is built here. Note the legacy Token path may
161 * provision a fresh JWT to authenticate the DELETE — Consent_Handler::revoke_consent()
162 * invalidates any locally stored JWTs afterwards, so credentials never outlive consent.
163 *
164 * @param WP_User $user The WP user revoking consent.
165 *
166 * @return Response The parsed response.
167 */
168 public function revoke_consent( WP_User $user ): Response {
169 return $this->send( new Request( '/user/consent', [], [], Request::METHOD_DELETE ), $user );
170 }
171
172 /**
173 * Sends an authenticated AI request, falling back to the secondary strategy on persistent failure.
174 *
175 * The fallback is only tried for failures that mean the primary strategy could not authenticate
176 * or reach the service; every authoritative answer the service gave (a content-filter rejection,
177 * a missing license, a consent or scope 403, a rate limit, a server error) propagates to the
178 * caller instead — see {@see self::is_fallback_eligible()}.
179 *
180 * Kept public for backward compatibility — callers that have a pre-built Request may dispatch it
181 * directly. New call sites should prefer the named methods above.
182 *
183 * @param Request $request The base request, without auth headers.
184 * @param WP_User $user The WP user the request is on behalf of.
185 *
186 * @return Response The parsed response.
187 */
188 public function send( Request $request, WP_User $user ): Response {
189 try {
190 return $this->primary->send( $request, $user );
191 }
192 catch ( Remote_Request_Exception $exception ) {
193 if ( ! $this->is_fallback_eligible( $exception ) ) {
194 $this->logger->warning(
195 'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); the failure is not recoverable by the fallback, propagating to the caller.',
196 $this->error_context( $exception ),
197 );
198 throw $exception;
199 }
200 if ( $this->fallback === null ) {
201 $this->logger->warning(
202 'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); no fallback configured, giving up.',
203 $this->error_context( $exception ),
204 );
205 throw $exception;
206 }
207 $this->logger->warning(
208 'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); falling back to the secondary strategy.',
209 $this->error_context( $exception ),
210 );
211
212 try {
213 $response = $this->fallback->send( $request, $user );
214 }
215 catch ( Remote_Request_Exception $fallback_exception ) {
216 $this->logger->warning(
217 'Secondary AI auth strategy also failed ({error_id}, HTTP {status}: {message}); giving up.',
218 $this->error_context( $fallback_exception ),
219 );
220 throw $fallback_exception;
221 }
222
223 $this->logger->debug( 'Secondary AI auth strategy succeeded after primary failure.' );
224 return $response;
225 }
226 }
227
228 /**
229 * Decides whether a primary-strategy failure may be retried via the fallback strategy.
230 *
231 * The fallback exists to recover from a primary strategy that could not *authenticate* or *reach*
232 * the service. It must not fire on an authoritative answer the service gave about the request, the
233 * user, or the account, because the fallback talks to the same service for the same user and would
234 * either get the same answer or, worse, fail for an unrelated reason and mask the real one — for
235 * example a 400 content-filter rejection (profanity) would be silently turned into a repeat request.
236 *
237 * Only three failures clear this gate, all of them about the primary strategy itself rather than
238 * the service's verdict: {@see Auth_Strategy_Unavailable_Exception} (no OAuth site token could be
239 * acquired), {@see Unauthorized_Exception} (a 401 — the token is missing, invalid, or expired), and
240 * {@see WP_Request_Exception} (a transport failure reaching the service, the very case OAuth exists
241 * to survive — note it carries status 400 by convention but is a transport failure, not a service
242 * answer). Every other {@see Remote_Request_Exception} — a service-issued 400
243 * ({@see \Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception}, e.g. a content
244 * filter), 402, 403 (consent, scope, or any other forbidden), 404, 408, 429, 500, 503 — is an
245 * authoritative answer that propagates untouched.
246 *
247 * @param Remote_Request_Exception $exception The failure thrown by the primary strategy.
248 *
249 * @return bool True when the fallback may be tried, false when the failure must propagate.
250 */
251 private function is_fallback_eligible( Remote_Request_Exception $exception ): bool {
252 return $exception instanceof Auth_Strategy_Unavailable_Exception
253 || $exception instanceof Unauthorized_Exception
254 || $exception instanceof WP_Request_Exception;
255 }
256
257 /**
258 * Builds the PSR-3 log context for a failed remote request, defaulting a missing error identifier
259 * to `unknown` so the rendered message never has an empty slot.
260 *
261 * @param Remote_Request_Exception $exception The failed request exception.
262 *
263 * @return array<string, int|string> The log context: error_id, status, message.
264 */
265 private function error_context( Remote_Request_Exception $exception ): array {
266 $error_id = $exception->get_error_identifier();
267 if ( $error_id === '' ) {
268 $error_id = 'unknown';
269 }
270
271 return [
272 'error_id' => $error_id,
273 'status' => $exception->getCode(),
274 'message' => $exception->getMessage(),
275 ];
276 }
277
278 // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.Missing
279 }
280