PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 All 202 releases
betterdocs / includes / AI / Providers / BaseProvider.php

BaseProvider.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.4, at includes/AI/Providers/BaseProvider.php

382 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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