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 / GeminiProvider.php

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

239 lines 9.1 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 /**
6 * Google Gemini provider (Generative Language API).
7 *
8 * Wire format differs from OpenAI: the system prompt goes in
9 * `system_instruction`, the conversation in `contents[].parts[].text` with
10 * roles user/model, and limits live under `generationConfig`. Auth is the
11 * `x-goog-api-key` header.
12 *
13 * @since 4.4.0
14 */
15 class GeminiProvider extends BaseProvider {
16
17 public function id() {
18 return 'gemini';
19 }
20
21 public function label() {
22 return 'Google Gemini';
23 }
24
25 /**
26 * @return string API base, no trailing slash.
27 */
28 protected function base_url() {
29 return 'https://generativelanguage.googleapis.com/v1beta';
30 }
31
32 /**
33 * Split normalized messages into a Gemini system_instruction string and a
34 * contents array with user/model roles.
35 *
36 * @param array $messages
37 * @return array array( string $system, array $contents )
38 */
39 protected function map_messages( $messages ) {
40 $system = array();
41 $contents = array();
42
43 foreach ( $messages as $message ) {
44 $role = isset( $message['role'] ) ? $message['role'] : 'user';
45 $content = isset( $message['content'] ) ? (string) $message['content'] : '';
46
47 if ( 'system' === $role ) {
48 $system[] = $content;
49 continue;
50 }
51
52 $contents[] = array(
53 'role' => ( 'assistant' === $role ) ? 'model' : 'user',
54 'parts' => array( array( 'text' => $content ) ),
55 );
56 }
57
58 return array( implode( "\n\n", $system ), $contents );
59 }
60
61 /**
62 * {@inheritDoc}
63 */
64 public function chat( $messages, $options = array() ) {
65 if ( empty( $this->api_key ) ) {
66 return new \WP_Error( 'no_api_key', sprintf( __( '%s API key is not configured.', 'betterdocs' ), $this->label() ) );
67 }
68
69 $model = $this->resolve_model( $options );
70 $context = isset( $options['context'] ) ? $options['context'] : null;
71 $max_tokens = $this->floor_tokens( isset( $options['max_tokens'] ) ? $options['max_tokens'] : 2500, $model, $context );
72
73 list( $system, $contents ) = $this->map_messages( $messages );
74
75 $payload = array(
76 'contents' => $contents,
77 'generationConfig' => array( 'maxOutputTokens' => (int) $max_tokens ),
78 );
79 if ( '' !== $system ) {
80 $payload['system_instruction'] = array( 'parts' => array( array( 'text' => $system ) ) );
81 }
82 if ( isset( $options['temperature'] ) && null !== $options['temperature'] ) {
83 $payload['generationConfig']['temperature'] = (float) $options['temperature'];
84 }
85
86 $url = $this->base_url() . '/models/' . rawurlencode( $model ) . ':generateContent';
87 $headers = array( 'Content-Type' => 'application/json', 'x-goog-api-key' => $this->api_key );
88 $timeout = isset( $options['timeout'] ) ? $options['timeout'] : 50;
89
90 $status = null;
91 $data = $this->post_json( $url, $headers, $payload, $timeout, $status );
92 if ( is_wp_error( $data ) ) {
93 return $data;
94 }
95
96 if ( ! empty( $data['error'] ) ) {
97 $raw = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'Unknown API error.', 'betterdocs' );
98 // Classify by HTTP status so a retired model (404) is not reported as a
99 // quota error (429) — the Gemini API returns the raw text either way.
100 return new \WP_Error( 'provider_error', $this->classify_http_error( $status, $raw, $model ) );
101 }
102
103 $content = $this->extract_text( $data );
104 if ( '' === $content ) {
105 return new \WP_Error( 'no_content', sprintf( __( 'No content received from %s.', 'betterdocs' ), $this->label() ) );
106 }
107
108 $usage = $this->normalize_usage(
109 isset( $data['usageMetadata'] ) && is_array( $data['usageMetadata'] ) ? $data['usageMetadata'] : array(),
110 array( 'prompt' => 'promptTokenCount', 'completion' => 'candidatesTokenCount', 'total' => 'totalTokenCount' )
111 );
112
113 return $this->success(
114 $content,
115 $model,
116 $usage,
117 isset( $data['candidates'][0]['finishReason'] ) ? $data['candidates'][0]['finishReason'] : null
118 );
119 }
120
121 /**
122 * Transcribe audio or video.
123 *
124 * Gemini has no dedicated speech endpoint — the media rides in the ordinary
125 * `generateContent` call as an `inline_data` part beside a text instruction,
126 * and the model returns the transcript as its answer. Inline data is base64,
127 * which inflates the payload by roughly a third; the caller caps media at
128 * 15 MB for this platform so the request stays inside Gemini's inline
129 * ceiling. (Files API upload would lift that, and is the obvious next step
130 * if longer recordings are ever needed.)
131 *
132 * @param array $file `[ 'path', 'filename', 'mime' ]`
133 * @param array $options `[ 'model', 'timeout', 'max_tokens' ]`
134 * @return string|\WP_Error
135 */
136 public function transcribe( $file, $options = array() ) {
137 if ( empty( $this->api_key ) ) {
138 return new \WP_Error( 'no_api_key', sprintf( __( '%s API key is not configured.', 'betterdocs' ), $this->label() ) );
139 }
140
141 $bytes = @file_get_contents( $file['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp upload, not a remote fetch.
142 if ( false === $bytes ) {
143 return new \WP_Error( 'api_error', __( 'Could not read the uploaded file.', 'betterdocs' ) );
144 }
145
146 $model = ! empty( $options['model'] ) ? (string) $options['model'] : 'gemini-flash-latest';
147 $timeout = isset( $options['timeout'] ) ? (int) $options['timeout'] : 120;
148
149 $payload = array(
150 'contents' => array(
151 array(
152 'role' => 'user',
153 'parts' => array(
154 array( 'text' => __( 'Transcribe the speech in this recording verbatim as plain text. Do not summarise, translate or add commentary. If there is no speech, reply with nothing at all.', 'betterdocs' ) ),
155 array( 'inline_data' => array(
156 'mime_type' => $file['mime'],
157 'data' => base64_encode( $bytes ),
158 ) ),
159 ),
160 ),
161 ),
162 'generationConfig' => array(
163 'maxOutputTokens' => isset( $options['max_tokens'] ) ? (int) $options['max_tokens'] : 8192,
164 ),
165 );
166
167 unset( $bytes );
168
169 $url = $this->base_url() . '/models/' . rawurlencode( $model ) . ':generateContent';
170 $status = null;
171 $data = $this->post_json(
172 $url,
173 array( 'Content-Type' => 'application/json', 'x-goog-api-key' => $this->api_key ),
174 $payload,
175 $timeout,
176 $status
177 );
178
179 if ( is_wp_error( $data ) ) {
180 return $data;
181 }
182
183 if ( ! empty( $data['error'] ) ) {
184 $raw = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'Unknown API error.', 'betterdocs' );
185 return new \WP_Error( 'provider_error', $this->classify_http_error( $status, $raw, $model ) );
186 }
187
188 return $this->extract_text( $data );
189 }
190
191 /**
192 * Concatenate all text parts of the first candidate.
193 *
194 * @param array $data
195 * @return string
196 */
197 protected function extract_text( $data ) {
198 if ( empty( $data['candidates'][0]['content']['parts'] ) || ! is_array( $data['candidates'][0]['content']['parts'] ) ) {
199 return '';
200 }
201 $text = '';
202 foreach ( $data['candidates'][0]['content']['parts'] as $part ) {
203 if ( isset( $part['text'] ) ) {
204 $text .= $part['text'];
205 }
206 }
207 return $text;
208 }
209
210 /**
211 * {@inheritDoc}
212 */
213 public function validate_key( $api_key = '' ) {
214 $api_key = $api_key !== '' ? $api_key : $this->api_key;
215
216 if ( empty( $api_key ) ) {
217 return array( 'valid' => false, 'message' => __( 'Please insert your API key to use AI features.', 'betterdocs' ) );
218 }
219
220 $response = wp_remote_get( $this->base_url() . '/models', array(
221 'headers' => array( 'x-goog-api-key' => $api_key ),
222 'timeout' => 15,
223 ) );
224
225 if ( is_wp_error( $response ) ) {
226 return array( 'valid' => false, 'message' => $response->get_error_message() );
227 }
228
229 $code = (int) wp_remote_retrieve_response_code( $response );
230 if ( 200 === $code ) {
231 return array( 'valid' => true, 'message' => __( 'Valid API Key', 'betterdocs' ) );
232 }
233
234 $body = json_decode( wp_remote_retrieve_body( $response ), true );
235 $message = isset( $body['error']['message'] ) ? $body['error']['message'] : __( 'Invalid API Key', 'betterdocs' );
236 return array( 'valid' => false, 'message' => $message );
237 }
238 }
239