| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\AI\Providers; |
| 4 |
|
| 5 |
use WPDeveloper\BetterDocs\AI\ModelRegistry; |
| 6 |
use WPDeveloper\BetterDocs\AI\Contracts\AIProvider; |
| 7 |
use WPDeveloper\BetterDocs\Utils\AIHelper; |
| 8 |
|
| 9 |
/** |
| 10 |
* Shared plumbing for concrete providers: construction, model lookup, the |
| 11 |
* min-token floor, HTTP transport, and usage normalization. Concrete providers |
| 12 |
* implement id()/label()/chat()/validate_key() and reuse the helpers here. |
| 13 |
* |
| 14 |
* @since 4.4.0 |
| 15 |
*/ |
| 16 |
abstract class BaseProvider implements AIProvider { |
| 17 |
|
| 18 |
/** |
| 19 |
* @var string Configured API key. |
| 20 |
*/ |
| 21 |
protected $api_key; |
| 22 |
|
| 23 |
/** |
| 24 |
* @var string Configured model id. |
| 25 |
*/ |
| 26 |
protected $model; |
| 27 |
|
| 28 |
/** |
| 29 |
* @param string $api_key Configured key for this platform. |
| 30 |
* @param string $model Configured model id; falls back to the platform default. |
| 31 |
*/ |
| 32 |
public function __construct( $api_key = '', $model = '' ) { |
| 33 |
$this->api_key = (string) $api_key; |
| 34 |
$this->model = $model !== '' ? (string) $model : $this->default_model(); |
| 35 |
} |
| 36 |
|
| 37 |
/** |
| 38 |
* {@inheritDoc} |
| 39 |
*/ |
| 40 |
public function models() { |
| 41 |
return ModelRegistry::models( $this->id() ); |
| 42 |
} |
| 43 |
|
| 44 |
/** |
| 45 |
* {@inheritDoc} |
| 46 |
*/ |
| 47 |
public function default_model() { |
| 48 |
return ModelRegistry::default_model( $this->id() ); |
| 49 |
} |
| 50 |
|
| 51 |
/** |
| 52 |
* Resolve the model to use for a request (option override wins). |
| 53 |
* |
| 54 |
* @param array $options |
| 55 |
* @return string |
| 56 |
*/ |
| 57 |
protected function resolve_model( $options ) { |
| 58 |
return ! empty( $options['model'] ) ? (string) $options['model'] : $this->model; |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* Apply the per-feature min-token floor when a context is supplied. |
| 63 |
* |
| 64 |
* Reuses the existing policy in AIHelper so the floor stays consistent with |
| 65 |
* the React notice and server-side save validation. |
| 66 |
* |
| 67 |
* @param int $max_tokens |
| 68 |
* @param string $model |
| 69 |
* @param string|null $context |
| 70 |
* @return int |
| 71 |
*/ |
| 72 |
protected function floor_tokens( $max_tokens, $model, $context = null ) { |
| 73 |
$max_tokens = (int) $max_tokens; |
| 74 |
if ( null === $context ) { |
| 75 |
return $max_tokens; |
| 76 |
} |
| 77 |
$min = AIHelper::get_min_tokens( $context, $model ); |
| 78 |
return ( $min > 0 && $max_tokens < $min ) ? $min : $max_tokens; |
| 79 |
} |
| 80 |
|
| 81 |
/** |
| 82 |
* POST JSON and decode the response into an array (or WP_Error). |
| 83 |
* |
| 84 |
* @param string $url |
| 85 |
* @param array $headers |
| 86 |
* @param array $body |
| 87 |
* @param int $timeout |
| 88 |
* @param int|null $status_code Out-param: set to the HTTP response code (0 on |
| 89 |
* transport failure) so callers can classify errors |
| 90 |
* by status without re-reading the response. |
| 91 |
* @return array|\WP_Error Decoded body array, or WP_Error on transport failure. |
| 92 |
*/ |
| 93 |
protected function post_json( $url, $headers, $body, $timeout = 50, &$status_code = null ) { |
| 94 |
$headers = wp_parse_args( $headers, array( 'Content-Type' => 'application/json' ) ); |
| 95 |
|
| 96 |
$response = wp_remote_post( $url, array( |
| 97 |
'headers' => $headers, |
| 98 |
'body' => wp_json_encode( $body ), |
| 99 |
'timeout' => (int) $timeout, |
| 100 |
) ); |
| 101 |
|
| 102 |
if ( is_wp_error( $response ) ) { |
| 103 |
$status_code = 0; |
| 104 |
return new \WP_Error( 'api_error', sprintf( |
| 105 |
/* translators: 1: provider label, 2: error message */ |
| 106 |
__( 'Failed to connect to %1$s: %2$s', 'betterdocs' ), |
| 107 |
$this->label(), |
| 108 |
$response->get_error_message() |
| 109 |
) ); |
| 110 |
} |
| 111 |
|
| 112 |
$status_code = (int) wp_remote_retrieve_response_code( $response ); |
| 113 |
|
| 114 |
$data = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 115 |
if ( ! is_array( $data ) ) { |
| 116 |
return new \WP_Error( 'no_content', sprintf( |
| 117 |
/* translators: %s: provider label */ |
| 118 |
__( 'Empty or invalid response from %s.', 'betterdocs' ), |
| 119 |
$this->label() |
| 120 |
) ); |
| 121 |
} |
| 122 |
|
| 123 |
return $data; |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* POST a multipart/form-data body carrying one file, and decode the response. |
| 128 |
* |
| 129 |
* The JSON helper above cannot express a file upload, and WordPress ships no |
| 130 |
* multipart builder — `wp_remote_post()` sends `body` as an array only as |
| 131 |
* urlencoded form fields. So the body is assembled by hand here. The one |
| 132 |
* caller is OpenAI's transcription endpoint, which takes the audio as a real |
| 133 |
* file part; everything else in this layer is JSON. |
| 134 |
* |
| 135 |
* The file is streamed in from its path rather than passed around as a |
| 136 |
* string by callers, so the bytes live in exactly one variable and are |
| 137 |
* released when this method returns. |
| 138 |
* |
| 139 |
* @param string $url |
| 140 |
* @param array $headers Auth headers. Content-Type is set here — a |
| 141 |
* caller-supplied one would lack the boundary. |
| 142 |
* @param array $fields Scalar form fields, e.g. `model`. |
| 143 |
* @param array $file `[ 'name' => field name, 'filename' => …, 'type' => mime, 'path' => … ]` |
| 144 |
* @param int $timeout |
| 145 |
* @param int|null $status_code Out-param, as in post_json(). |
| 146 |
* @return array|\WP_Error |
| 147 |
*/ |
| 148 |
protected function post_multipart( $url, $headers, $fields, $file, $timeout = 120, &$status_code = null ) { |
| 149 |
if ( ! is_readable( $file['path'] ) ) { |
| 150 |
$status_code = 0; |
| 151 |
return new \WP_Error( 'api_error', __( 'Could not read the uploaded file.', 'betterdocs' ) ); |
| 152 |
} |
| 153 |
|
| 154 |
$response = $this->post_multipart_streamed( $url, $headers, $fields, $file, $timeout ); |
| 155 |
|
| 156 |
if ( null === $response ) { |
| 157 |
$response = $this->post_multipart_buffered( $url, $headers, $fields, $file, $timeout ); |
| 158 |
} |
| 159 |
|
| 160 |
if ( is_wp_error( $response ) ) { |
| 161 |
$status_code = 0; |
| 162 |
return new \WP_Error( 'api_error', sprintf( |
| 163 |
/* translators: 1: provider label, 2: error message */ |
| 164 |
__( 'Failed to connect to %1$s: %2$s', 'betterdocs' ), |
| 165 |
$this->label(), |
| 166 |
$response->get_error_message() |
| 167 |
) ); |
| 168 |
} |
| 169 |
|
| 170 |
$status_code = (int) wp_remote_retrieve_response_code( $response ); |
| 171 |
|
| 172 |
$data = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 173 |
if ( ! is_array( $data ) ) { |
| 174 |
return new \WP_Error( 'no_content', sprintf( |
| 175 |
/* translators: %s: provider label */ |
| 176 |
__( 'Empty or invalid response from %s.', 'betterdocs' ), |
| 177 |
$this->label() |
| 178 |
) ); |
| 179 |
} |
| 180 |
|
| 181 |
return $data; |
| 182 |
} |
| 183 |
|
| 184 |
/** |
| 185 |
* Send the multipart body with cURL reading the file straight from disk. |
| 186 |
* |
| 187 |
* Building the body as a PHP string holds the whole recording in memory two |
| 188 |
* or three times over (the bytes, the body, cURL's own copy) — a 24 MB file |
| 189 |
* pushed a 128M host over its limit. Handing cURL a CURLFile instead lets it |
| 190 |
* stream the part from the temp upload, so the file is never in PHP memory. |
| 191 |
* |
| 192 |
* The request still goes through wp_remote_post(), so proxies, SSL settings |
| 193 |
* and every `http_*` filter apply as normal; only the body and the headers |
| 194 |
* are replaced on the handle, right before it is sent. |
| 195 |
* |
| 196 |
* @return array|\WP_Error|null Null when cURL is not the transport, so the |
| 197 |
* caller can fall back to the buffered body. |
| 198 |
*/ |
| 199 |
protected function post_multipart_streamed( $url, $headers, $fields, $file, $timeout ) { |
| 200 |
if ( ! function_exists( 'curl_init' ) || ! class_exists( '\CURLFile' ) ) { |
| 201 |
return null; |
| 202 |
} |
| 203 |
|
| 204 |
$streamed = false; |
| 205 |
$attach = static function ( $handle, $r, $request_url ) use ( $url, $headers, $fields, $file, &$streamed ) { |
| 206 |
if ( $request_url !== $url || $streamed ) { |
| 207 |
return; |
| 208 |
} |
| 209 |
|
| 210 |
$post = $fields; |
| 211 |
$post[ $file['name'] ] = new \CURLFile( $file['path'], $file['type'], $file['filename'] ); |
| 212 |
|
| 213 |
// cURL writes its own multipart Content-Type, boundary included, so |
| 214 |
// the header list is set without one. `Expect:` stops a 100-continue |
| 215 |
// round trip on large bodies. |
| 216 |
$lines = array( 'Expect:' ); |
| 217 |
foreach ( $headers as $name => $value ) { |
| 218 |
if ( 'content-type' !== strtolower( $name ) ) { |
| 219 |
$lines[] = $name . ': ' . $value; |
| 220 |
} |
| 221 |
} |
| 222 |
|
| 223 |
curl_setopt( $handle, CURLOPT_POSTFIELDS, $post ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- the handle WordPress is about to send. |
| 224 |
curl_setopt( $handle, CURLOPT_HTTPHEADER, $lines ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt |
| 225 |
$streamed = true; |
| 226 |
}; |
| 227 |
|
| 228 |
add_action( 'http_api_curl', $attach, PHP_INT_MAX, 3 ); |
| 229 |
|
| 230 |
$response = wp_remote_post( $url, array( |
| 231 |
'headers' => $headers, |
| 232 |
// Replaced on the handle above. Non-empty so the request is sent |
| 233 |
// as a POST with a body on every transport. |
| 234 |
'body' => ' ', |
| 235 |
'timeout' => (int) $timeout, |
| 236 |
) ); |
| 237 |
|
| 238 |
remove_action( 'http_api_curl', $attach, PHP_INT_MAX ); |
| 239 |
|
| 240 |
// The hook never ran: something other than cURL sent that request, |
| 241 |
// carrying the one-byte placeholder. Nothing was transcribed (the |
| 242 |
// provider rejects an empty upload), so the buffered path runs it again. |
| 243 |
return $streamed ? $response : null; |
| 244 |
} |
| 245 |
|
| 246 |
/** |
| 247 |
* The same request with the body built in PHP, for a host without cURL. |
| 248 |
* |
| 249 |
* @return array|\WP_Error |
| 250 |
*/ |
| 251 |
protected function post_multipart_buffered( $url, $headers, $fields, $file, $timeout ) { |
| 252 |
$boundary = wp_generate_password( 24, false ); |
| 253 |
$eol = "\r\n"; |
| 254 |
$body = ''; |
| 255 |
|
| 256 |
foreach ( $fields as $key => $value ) { |
| 257 |
$body .= '--' . $boundary . $eol; |
| 258 |
$body .= 'Content-Disposition: form-data; name="' . $key . '"' . $eol . $eol; |
| 259 |
$body .= $value . $eol; |
| 260 |
} |
| 261 |
|
| 262 |
$body .= '--' . $boundary . $eol; |
| 263 |
$body .= 'Content-Disposition: form-data; name="' . $file['name'] . '"; filename="' . $file['filename'] . '"' . $eol; |
| 264 |
$body .= 'Content-Type: ' . $file['type'] . $eol . $eol; |
| 265 |
// Appended straight onto the body, never as `$bytes . $eol`: that |
| 266 |
// expression builds a third full-size copy of the file before the append. |
| 267 |
$body .= (string) @file_get_contents( $file['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp upload, not a remote fetch. |
| 268 |
$body .= $eol; |
| 269 |
$body .= '--' . $boundary . '--' . $eol; |
| 270 |
|
| 271 |
$headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; |
| 272 |
|
| 273 |
return wp_remote_post( $url, array( |
| 274 |
'headers' => $headers, |
| 275 |
'body' => $body, |
| 276 |
'timeout' => (int) $timeout, |
| 277 |
) ); |
| 278 |
} |
| 279 |
|
| 280 |
/** |
| 281 |
* Transcribe an audio or video file to plain text. |
| 282 |
* |
| 283 |
* Default: not supported. A provider that can do it overrides this; callers |
| 284 |
* are expected to check Core\WriteWithAI::platform_supports( 'transcription' ) |
| 285 |
* first and show the switch-platform message, so reaching this is a bug |
| 286 |
* rather than a user-facing path. |
| 287 |
* |
| 288 |
* @param array $file `[ 'path', 'filename', 'mime' ]` |
| 289 |
* @param array $options |
| 290 |
* @return string|\WP_Error Transcript text. |
| 291 |
*/ |
| 292 |
public function transcribe( $file, $options = array() ) { |
| 293 |
return new \WP_Error( 'no_transcription', sprintf( |
| 294 |
/* translators: %s: provider label */ |
| 295 |
__( '%s cannot transcribe audio or video.', 'betterdocs' ), |
| 296 |
$this->label() |
| 297 |
) ); |
| 298 |
} |
| 299 |
|
| 300 |
/** |
| 301 |
* Turn an HTTP status + the provider's raw error text into a clear, actionable |
| 302 |
* message. Chiefly distinguishes a retired/unknown model (404) from a genuine |
| 303 |
* quota / rate-limit rejection (429) — without this, a dead model id and a real |
| 304 |
* quota error both surface the provider's raw text and look identical (a retired |
| 305 |
* model reads like "quota exceeded"). Falls back to the raw message otherwise. |
| 306 |
* |
| 307 |
* @param int $status HTTP status code. |
| 308 |
* @param string $raw_message Provider-supplied error message. |
| 309 |
* @param string $model Model id in play, for the "unavailable" message. |
| 310 |
* @return string |
| 311 |
*/ |
| 312 |
protected function classify_http_error( $status, $raw_message, $model = '' ) { |
| 313 |
$status = (int) $status; |
| 314 |
$raw = strtolower( (string) $raw_message ); |
| 315 |
|
| 316 |
if ( 404 === $status || false !== strpos( $raw, 'not found' ) || false !== strpos( $raw, 'is not supported' ) ) { |
| 317 |
return sprintf( |
| 318 |
/* translators: 1: provider label, 2: model id */ |
| 319 |
__( 'The %1$s model "%2$s" is unavailable — it may have been retired. Choose a different model.', 'betterdocs' ), |
| 320 |
$this->label(), |
| 321 |
$model |
| 322 |
); |
| 323 |
} |
| 324 |
|
| 325 |
if ( 429 === $status || false !== strpos( $raw, 'resource_exhausted' ) || false !== strpos( $raw, 'quota' ) || false !== strpos( $raw, 'rate limit' ) ) { |
| 326 |
return sprintf( |
| 327 |
/* translators: %s: provider label */ |
| 328 |
__( 'Your %s request hit a quota or rate limit. Check your plan and limits, then try again.', 'betterdocs' ), |
| 329 |
$this->label() |
| 330 |
); |
| 331 |
} |
| 332 |
|
| 333 |
return (string) $raw_message; |
| 334 |
} |
| 335 |
|
| 336 |
/** |
| 337 |
* Normalize a usage block into prompt/completion/total token counts. |
| 338 |
* |
| 339 |
* @param array $usage Provider-specific usage payload. |
| 340 |
* @param array $map Keys map: array( 'prompt'=>..., 'completion'=>..., 'total'=>... ). |
| 341 |
* @return array |
| 342 |
*/ |
| 343 |
protected function normalize_usage( $usage, $map ) { |
| 344 |
$get = function ( $key ) use ( $usage ) { |
| 345 |
return ( $key && isset( $usage[ $key ] ) ) ? (int) $usage[ $key ] : null; |
| 346 |
}; |
| 347 |
|
| 348 |
$prompt = $get( isset( $map['prompt'] ) ? $map['prompt'] : null ); |
| 349 |
$completion = $get( isset( $map['completion'] ) ? $map['completion'] : null ); |
| 350 |
$total = $get( isset( $map['total'] ) ? $map['total'] : null ); |
| 351 |
|
| 352 |
if ( null === $total && ( null !== $prompt || null !== $completion ) ) { |
| 353 |
$total = (int) $prompt + (int) $completion; |
| 354 |
} |
| 355 |
|
| 356 |
return array( |
| 357 |
'prompt_tokens' => $prompt, |
| 358 |
'completion_tokens' => $completion, |
| 359 |
'total_tokens' => $total, |
| 360 |
); |
| 361 |
} |
| 362 |
|
| 363 |
/** |
| 364 |
* Build the normalized success envelope returned by chat(). |
| 365 |
* |
| 366 |
* @param string $content |
| 367 |
* @param string $model |
| 368 |
* @param array $usage |
| 369 |
* @param string|null $finish_reason |
| 370 |
* @return array |
| 371 |
*/ |
| 372 |
protected function success( $content, $model, $usage, $finish_reason = null ) { |
| 373 |
return array( |
| 374 |
'success' => true, |
| 375 |
'content' => (string) $content, |
| 376 |
'model' => (string) $model, |
| 377 |
'usage' => $usage, |
| 378 |
'finish_reason' => $finish_reason, |
| 379 |
); |
| 380 |
} |
| 381 |
} |
| 382 |
|