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
← All changes | includes/AI/Providers/BaseProvider.php +174 -0 4.9.1 → 4.9.4 View file →
@@ -123,8 +123,182 @@
123 123 return $data;
124 124 }
125 125
126 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 + /**
127 301 * Turn an HTTP status + the provider's raw error text into a clear, actionable
128 302 * message. Chiefly distinguishes a retired/unknown model (404) from a genuine
129 303 * quota / rate-limit rejection (429) — without this, a dead model id and a real
130 304 * quota error both surface the provider's raw text and look identical (a retired