PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.2
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.2
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
wpfunnels / includes / core / AI / Providers / AbstractProvider.php

AbstractProvider.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.2, at includes/core/AI/Providers/AbstractProvider.php

320 lines 8.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AbstractProvider — shared HTTP plumbing for AI provider adapters.
4 *
5 * Includes a per-provider circuit breaker: after repeated failures the adapter
6 * refuses to call out for a cool-off window instead of burning a request (and
7 * the user's patience) on every loop step. The breaker never sleeps — a
8 * blocking retry inside a synchronous REST request is worse than failing fast.
9 *
10 * @package WPFunnels\AI
11 * @since 3.13.0
12 */
13
14 namespace WPFunnels\AI\Providers;
15
16 defined( 'ABSPATH' ) || exit;
17
18 /**
19 * Class AbstractProvider
20 */
21 abstract class AbstractProvider implements ProviderInterface {
22
23 /**
24 * Per-step output ceiling. Deliberately below the SDK-default 16K: each
25 * agent-loop step runs inside one synchronous REST request, and shared
26 * hosting PHP timeouts are the binding constraint, not output length.
27 */
28 protected const MAX_TOKENS = 8192;
29
30 /**
31 * HTTP timeout for a chat call, in seconds.
32 */
33 protected const HTTP_TIMEOUT = 90;
34
35 /**
36 * Consecutive failures that trip the breaker.
37 */
38 protected const BREAKER_THRESHOLD = 5;
39
40 /**
41 * How long the breaker stays open, in seconds.
42 */
43 protected const BREAKER_COOLOFF = 300;
44
45 /**
46 * API key for the provider ('' for wordpress_ai).
47 *
48 * @var string
49 */
50 protected $api_key;
51
52 /**
53 * Model id.
54 *
55 * @var string
56 */
57 protected $model;
58
59 /**
60 * Constructor.
61 *
62 * @param string $api_key Plaintext API key.
63 * @param string $model Model id.
64 */
65 public function __construct( $api_key, $model ) {
66 $this->api_key = (string) $api_key;
67 $this->model = (string) $model;
68 }
69
70 /**
71 * Whether the breaker is currently open for this provider.
72 *
73 * @return bool
74 */
75 public function isCircuitOpen() {
76 $state = $this->breakerState();
77 if ( empty( $state['open_until'] ) ) {
78 return false;
79 }
80 if ( time() >= (int) $state['open_until'] ) {
81 $this->resetCircuit();
82 return false;
83 }
84 return true;
85 }
86
87 /**
88 * Seconds until the breaker closes again.
89 *
90 * @return int
91 */
92 public function circuitCooloffRemaining() {
93 $state = $this->breakerState();
94 return empty( $state['open_until'] ) ? 0 : max( 0, (int) $state['open_until'] - time() );
95 }
96
97 /**
98 * POST JSON and decode the response, mapping HTTP failures to WP_Error.
99 *
100 * @param string $url Endpoint.
101 * @param array $headers Extra headers.
102 * @param array $body Request body.
103 * @return array|\WP_Error Decoded JSON body.
104 */
105 protected function postJson( $url, $headers, $body ) {
106 if ( $this->isCircuitOpen() ) {
107 return new \WP_Error(
108 'ai_circuit_open',
109 sprintf(
110 /* translators: %1$s: provider slug, %2$d: seconds remaining. */
111 __( '%1$s failed repeatedly and is paused. Try again in %2$d seconds.', 'wpfnl' ),
112 $this->slug(),
113 $this->circuitCooloffRemaining()
114 )
115 );
116 }
117
118 $response = wp_remote_post(
119 $url,
120 [
121 'timeout' => self::HTTP_TIMEOUT,
122 'headers' => array_merge( [ 'Content-Type' => 'application/json' ], $headers ),
123 'body' => wp_json_encode( $body ),
124 ]
125 );
126
127 return $this->decodeResponse( $response );
128 }
129
130 /**
131 * GET and decode, same error mapping.
132 *
133 * @param string $url Endpoint.
134 * @param array $headers Extra headers.
135 * @return array|\WP_Error
136 */
137 protected function getJson( $url, $headers ) {
138 $response = wp_remote_get(
139 $url,
140 [
141 'timeout' => 30,
142 'headers' => $headers,
143 ]
144 );
145 return $this->decodeResponse( $response );
146 }
147
148 /**
149 * Decode a wp_remote_* result, recording breaker state on the way through.
150 *
151 * @param array|\WP_Error $response wp_remote_* result.
152 * @return array|\WP_Error
153 */
154 private function decodeResponse( $response ) {
155 if ( is_wp_error( $response ) ) {
156 $this->recordFailure();
157 return new \WP_Error(
158 'ai_network_error',
159 sprintf(
160 /* translators: %1$s: provider slug, %2$s: transport error. */
161 __( 'Could not reach %1$s: %2$s', 'wpfnl' ),
162 $this->slug(),
163 $response->get_error_message()
164 )
165 );
166 }
167
168 $code = (int) wp_remote_retrieve_response_code( $response );
169 $body = json_decode( wp_remote_retrieve_body( $response ), true );
170
171 if ( $code >= 200 && $code < 300 ) {
172 $this->resetCircuit();
173 return is_array( $body ) ? $body : [];
174 }
175
176 $detail = '';
177 if ( is_array( $body ) ) {
178 if ( isset( $body['error']['message'] ) ) {
179 $detail = $body['error']['message'];
180 } elseif ( isset( $body['message'] ) ) {
181 $detail = $body['message'];
182 } elseif ( isset( $body['error'] ) ) {
183 $detail = $body['error'];
184 }
185 $detail = is_string( $detail ) ? $detail : wp_json_encode( $detail );
186 }
187
188 $map = [
189 401 => [ 'ai_auth_error', __( 'The API key was rejected. Reconnect with a valid key.', 'wpfnl' ) ],
190 403 => [ 'ai_auth_error', __( 'The API key lacks permission for this request.', 'wpfnl' ) ],
191 404 => [ 'ai_model_error', __( 'The configured model was not found.', 'wpfnl' ) ],
192 429 => [ 'ai_rate_limited', __( 'The provider rate-limited the request. Try again shortly.', 'wpfnl' ) ],
193 529 => [ 'ai_overloaded', __( 'The provider is temporarily overloaded. Try again shortly.', 'wpfnl' ) ],
194 ];
195
196 if ( isset( $map[ $code ] ) ) {
197 $err_code = $map[ $code ][0];
198 $err_message = $map[ $code ][1];
199 } else {
200 $err_code = 'ai_provider_error';
201 /* translators: %d: HTTP status code. */
202 $err_message = sprintf( __( 'Provider returned HTTP %d.', 'wpfnl' ), $code );
203 }
204
205 // A rejected key or missing model will not fix itself — count those, and
206 // server-side failures, toward the breaker. Rate limits are transient and
207 // already carry their own retry signal.
208 if ( 429 !== $code ) {
209 $this->recordFailure();
210 }
211
212 $error_data = [ 'status' => $code ];
213 if ( 429 === $code ) {
214 // The provider tells us exactly how long to wait — surface it so
215 // AgentLoop can have the client retry automatically instead of
216 // dying with a red error banner over a few-second throttle.
217 // Prefer the standard Retry-After header; OpenAI's error message
218 // also states it in prose ("Please try again in 6.636s") as a
219 // fallback for providers/errors that omit the header.
220 $header_retry = wp_remote_retrieve_header( $response, 'retry-after' );
221 if ( '' !== $header_retry && is_numeric( $header_retry ) ) {
222 $error_data['retry_after'] = (float) $header_retry;
223 } elseif ( preg_match( '/try again in\s*([\d.]+)\s*s/i', $detail, $matches ) ) {
224 $error_data['retry_after'] = (float) $matches[1];
225 }
226 }
227
228 return new \WP_Error(
229 $err_code,
230 trim( $err_message . ( $detail ? ' (' . $detail . ')' : '' ) ),
231 $error_data
232 );
233 }
234
235 /**
236 * Decode a message row's JSON content into an array.
237 *
238 * @param array $message Message row.
239 * @return array
240 */
241 protected static function content( $message ) {
242 $content = isset( $message['content'] ) ? $message['content'] : [];
243 if ( is_string( $content ) ) {
244 $content = json_decode( $content, true );
245 }
246 return is_array( $content ) ? $content : [];
247 }
248
249 /**
250 * Provider-native raw payload stored on an assistant message, if it was
251 * produced by THIS provider (raw blocks are not portable across providers).
252 *
253 * @param array $message Message row.
254 * @return mixed|null
255 */
256 protected function rawFor( $message ) {
257 $meta = isset( $message['meta'] ) ? $message['meta'] : [];
258 if ( is_string( $meta ) ) {
259 $meta = json_decode( $meta, true );
260 }
261 if ( is_array( $meta ) && isset( $meta['provider'], $meta['raw'] ) && $meta['provider'] === $this->slug() ) {
262 return $meta['raw'];
263 }
264 return null;
265 }
266
267 // -------------------------------------------------------------------------
268 // Circuit breaker
269 // -------------------------------------------------------------------------
270
271 /**
272 * Option name holding this provider's breaker state.
273 *
274 * @return string
275 */
276 private function breakerOption() {
277 return '_wpfnl_ai_circuit_' . $this->slug();
278 }
279
280 /**
281 * Current breaker state.
282 *
283 * @return array
284 */
285 private function breakerState() {
286 $state = get_option( $this->breakerOption(), [] );
287 return is_array( $state ) ? $state : [];
288 }
289
290 /**
291 * Record a failure, tripping the breaker once the threshold is reached.
292 *
293 * @return void
294 */
295 private function recordFailure() {
296 $state = $this->breakerState();
297 $failures = isset( $state['failures'] ) ? (int) $state['failures'] + 1 : 1;
298
299 $state['failures'] = $failures;
300 $state['last_failure'] = time();
301 if ( $failures >= self::BREAKER_THRESHOLD ) {
302 $state['open_until'] = time() + self::BREAKER_COOLOFF;
303 }
304
305 update_option( $this->breakerOption(), $state, false );
306 }
307
308 /**
309 * Clear the breaker after a success or once the cool-off has elapsed.
310 *
311 * @return void
312 */
313 private function resetCircuit() {
314 if ( [] === $this->breakerState() ) {
315 return;
316 }
317 delete_option( $this->breakerOption() );
318 }
319 }
320