| 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 |
|