PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
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 3.5.0 3.5.1 All 200 releases
betterdocs / includes / AI / Providers / ClaudeProvider.php

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

232 lines 8.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 * Anthropic Claude provider (Messages API).
7 *
8 * Differences from OpenAI: auth via `x-api-key` + `anthropic-version`, the
9 * system prompt is a top-level `system` string (not a message), `max_tokens`
10 * is required, and the reply text lives in `content[].text` blocks.
11 *
12 * @since 4.4.0
13 */
14 class ClaudeProvider extends BaseProvider {
15
16 /**
17 * Anthropic API version header value.
18 */
19 const ANTHROPIC_VERSION = '2023-06-01';
20
21 public function id() {
22 return 'claude';
23 }
24
25 public function label() {
26 return 'Anthropic Claude';
27 }
28
29 protected function base_url() {
30 return 'https://api.anthropic.com/v1';
31 }
32
33 /**
34 * @param string $api_key
35 * @return array
36 */
37 protected function request_headers( $api_key ) {
38 return array(
39 'Content-Type' => 'application/json',
40 'x-api-key' => $api_key,
41 'anthropic-version' => self::ANTHROPIC_VERSION,
42 );
43 }
44
45 /**
46 * Whether a model rejects the sampling parameters (`temperature`, `top_p`,
47 * `top_k`) with a 400.
48 *
49 * Anthropic removed them starting with Opus 4.7; Sonnet 5 rejects any
50 * non-default value. Sending `temperature` to one of these fails the whole
51 * request — not a degraded answer, a hard error — so it is stripped rather
52 * than forwarded. Older models (Sonnet 4.5, Haiku 4.5, Opus 4.5/4.6) still
53 * accept it and are unaffected.
54 *
55 * @param string $model
56 * @return bool
57 */
58 protected function rejects_sampling( $model ) {
59 $model = (string) $model;
60
61 if ( in_array( $model, array( 'claude-opus-5', 'claude-sonnet-5' ), true ) ) {
62 return true;
63 }
64
65 // Opus 4.7 and every Opus release after it.
66 return (bool) preg_match( '/^claude-opus-4-(?:[7-9]|\d{2,})/', $model );
67 }
68
69 /**
70 * Whether a model thinks by default when no `thinking` parameter is sent.
71 *
72 * This matters for `max_tokens`, which caps thinking AND the visible answer
73 * together: on these models a budget sized for the answer alone can be spent
74 * mostly on reasoning and return a truncated document. Opus 4.7/4.8 do NOT
75 * think unless asked, so they are deliberately absent.
76 *
77 * @param string $model
78 * @return bool
79 */
80 protected function thinks_by_default( $model ) {
81 return in_array( (string) $model, array( 'claude-opus-5', 'claude-sonnet-5' ), true );
82 }
83
84 /**
85 * Split normalized messages into Claude's top-level system string and a
86 * user/assistant messages array.
87 *
88 * @param array $messages
89 * @return array array( string $system, array $messages )
90 */
91 protected function map_messages( $messages ) {
92 $system = array();
93 $turns = array();
94
95 foreach ( $messages as $message ) {
96 $role = isset( $message['role'] ) ? $message['role'] : 'user';
97 $content = isset( $message['content'] ) ? (string) $message['content'] : '';
98
99 if ( 'system' === $role ) {
100 $system[] = $content;
101 continue;
102 }
103
104 $turns[] = array(
105 'role' => ( 'assistant' === $role ) ? 'assistant' : 'user',
106 'content' => $content,
107 );
108 }
109
110 return array( implode( "\n\n", $system ), $turns );
111 }
112
113 /**
114 * {@inheritDoc}
115 */
116 public function chat( $messages, $options = array() ) {
117 if ( empty( $this->api_key ) ) {
118 return new \WP_Error( 'no_api_key', sprintf( __( '%s API key is not configured.', 'betterdocs' ), $this->label() ) );
119 }
120
121 $model = $this->resolve_model( $options );
122 $context = isset( $options['context'] ) ? $options['context'] : null;
123 $max_tokens = $this->floor_tokens( isset( $options['max_tokens'] ) ? $options['max_tokens'] : 2500, $model, $context );
124
125 // A thinking model spends part of max_tokens on reasoning the user never
126 // sees, so a budget sized for the answer alone returns a truncated one.
127 // Double it, since the alternative — disabling thinking — costs the quality
128 // that motivated choosing these models.
129 if ( $this->thinks_by_default( $model ) ) {
130 $max_tokens = (int) $max_tokens * 2;
131 }
132
133 list( $system, $turns ) = $this->map_messages( $messages );
134
135 $payload = array(
136 'model' => $model,
137 'max_tokens' => (int) $max_tokens,
138 'messages' => $turns,
139 );
140 if ( '' !== $system ) {
141 $payload['system'] = $system;
142 }
143 // Dropped rather than forwarded on models that reject it — see
144 // rejects_sampling(). Steering those models is done through the prompt.
145 if ( isset( $options['temperature'] ) && null !== $options['temperature']
146 && ! $this->rejects_sampling( $model ) ) {
147 $payload['temperature'] = (float) $options['temperature'];
148 }
149
150 $timeout = isset( $options['timeout'] ) ? $options['timeout'] : 50;
151
152 $status = null;
153 $data = $this->post_json( $this->base_url() . '/messages', $this->request_headers( $this->api_key ), $payload, $timeout, $status );
154 if ( is_wp_error( $data ) ) {
155 return $data;
156 }
157
158 if ( ! empty( $data['error'] ) ) {
159 $message = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'Unknown API error.', 'betterdocs' );
160 // Classify by HTTP status: Anthropic carries the reason in `error.type`
161 // (rate_limit_error, not_found_error) and leaves `error.message` free-form,
162 // so matching on the text alone is unreliable — the status is not.
163 return new \WP_Error( 'provider_error', $this->classify_http_error( $status, $message, $model ) );
164 }
165
166 $content = $this->extract_text( $data );
167 if ( '' === $content ) {
168 return new \WP_Error( 'no_content', sprintf( __( 'No content received from %s.', 'betterdocs' ), $this->label() ) );
169 }
170
171 $usage = $this->normalize_usage(
172 isset( $data['usage'] ) && is_array( $data['usage'] ) ? $data['usage'] : array(),
173 array( 'prompt' => 'input_tokens', 'completion' => 'output_tokens', 'total' => null )
174 );
175
176 return $this->success(
177 $content,
178 isset( $data['model'] ) ? $data['model'] : $model,
179 $usage,
180 isset( $data['stop_reason'] ) ? $data['stop_reason'] : null
181 );
182 }
183
184 /**
185 * Concatenate text blocks from the Messages API response.
186 *
187 * @param array $data
188 * @return string
189 */
190 protected function extract_text( $data ) {
191 if ( empty( $data['content'] ) || ! is_array( $data['content'] ) ) {
192 return '';
193 }
194 $text = '';
195 foreach ( $data['content'] as $block ) {
196 if ( isset( $block['type'], $block['text'] ) && 'text' === $block['type'] ) {
197 $text .= $block['text'];
198 }
199 }
200 return $text;
201 }
202
203 /**
204 * {@inheritDoc}
205 */
206 public function validate_key( $api_key = '' ) {
207 $api_key = $api_key !== '' ? $api_key : $this->api_key;
208
209 if ( empty( $api_key ) ) {
210 return array( 'valid' => false, 'message' => __( 'Please insert your API key to use AI features.', 'betterdocs' ) );
211 }
212
213 $response = wp_remote_get( $this->base_url() . '/models', array(
214 'headers' => $this->request_headers( $api_key ),
215 'timeout' => 15,
216 ) );
217
218 if ( is_wp_error( $response ) ) {
219 return array( 'valid' => false, 'message' => $response->get_error_message() );
220 }
221
222 $code = (int) wp_remote_retrieve_response_code( $response );
223 if ( 200 === $code ) {
224 return array( 'valid' => true, 'message' => __( 'Valid API Key', 'betterdocs' ) );
225 }
226
227 $body = json_decode( wp_remote_retrieve_body( $response ), true );
228 $message = isset( $body['error']['message'] ) ? $body['error']['message'] : __( 'Invalid API Key', 'betterdocs' );
229 return array( 'valid' => false, 'message' => $message );
230 }
231 }
232