PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/ai/class-claude-client.php +301 -17 1.0.2 → 2.9.0 View file →
@@ -11,21 +11,28 @@
11 11 declare(strict_types=1);
12 12
13 13 namespace ThinkRank\AI;
14 14
15 +use ThinkRank\AI\Traits\Request_Timeout;
16 +
15 17 // Prevent direct access
16 18 if (!defined('ABSPATH')) {
17 19 exit;
18 20 }
19 21
22 +require_once __DIR__ . '/traits/trait-request-timeout.php';
23 +
20 24 /**
21 25 * Claude Client Class
22 - *
26 + *
23 27 * Single Responsibility: Handle Claude API communication
24 - *
28 + *
25 29 * @since 1.0.0
26 30 */
27 31 class Claude_Client {
32 +
33 + use Request_Timeout;
34 +
28 35
29 36 /**
30 37 * Claude API base URL
31 38 */
@@ -66,11 +73,11 @@
66 73 * @param string $api_key Claude API key
67 74 * @param string $model Default model to use
68 75 * @param int $timeout Request timeout
69 76 */
70 - public function __construct(string $api_key, string $model = 'claude-3-7-sonnet-latest', int $timeout = 30) {
77 + public function __construct(string $api_key, string $model = \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL, int $timeout = 30) {
71 78 $this->api_key = $api_key;
72 - $this->model = $model;
79 + $this->model = self::normalize_model($model);
73 80 $this->timeout = $timeout;
74 81 }
75 82
76 83 /**
@@ -106,13 +113,15 @@
106 113 'temperature' => 0.7,
107 114 ];
108 115
109 116 $options = array_merge($default_options, $options);
110 -
117 +
118 + // Callers may override the model via $options; self-heal retired IDs here too.
119 + $options['model'] = self::normalize_model((string) $options['model']);
120 +
111 121 $body = [
112 122 'model' => $options['model'],
113 123 'max_tokens' => $options['max_tokens'],
114 - 'temperature' => $options['temperature'],
115 124 'messages' => [
116 125 [
117 126 'role' => 'user',
118 127 'content' => $prompt,
@@ -118,11 +127,68 @@
118 127 'content' => $prompt,
119 128 ]
120 129 ],
121 130 ];
122 -
131 +
132 + // Newer Claude models (Opus 4.7/4.8, Sonnet 5, Fable 5) reject non-default
133 + // sampling params with a 400. Only send `temperature` to models that accept it.
134 + if (!$this->model_rejects_sampling_params($options['model'])) {
135 + $body['temperature'] = $options['temperature'];
136 + }
137 +
123 138 return $this->make_request('messages', $body);
124 139 }
140 +
141 + /**
142 + * Remap retired / unavailable Claude model IDs to the current default.
143 + *
144 + * Existing installs may have a stored `claude_model` that Anthropic has since
145 + * retired (all `claude-3-*`) or deprecated to the point of returning 404
146 + * (the `claude-*-4-0` / dated 4.0 aliases). Those IDs are self-healed to the
147 + * recommended default so saved settings don't break API calls. A model not in
148 + * this list — including a valid current model or a user-entered custom ID — is
149 + * returned unchanged.
150 + *
151 + * @param string $model Model ID from settings
152 + * @return string A usable model ID
153 + */
154 + public static function normalize_model(string $model): string {
155 + $retired = [
156 + 'claude-3-7-sonnet-latest', 'claude-3-7-sonnet-20250219',
157 + 'claude-3-5-sonnet-latest', 'claude-3-5-sonnet-20241022', 'claude-3-5-sonnet-20240620',
158 + 'claude-3-5-haiku-latest', 'claude-3-5-haiku-20241022',
159 + 'claude-3-opus-latest', 'claude-3-opus-20240229',
160 + 'claude-3-sonnet-20240229', 'claude-3-haiku-20240307',
161 + 'claude-sonnet-4-0', 'claude-sonnet-4-20250514',
162 + 'claude-opus-4-0', 'claude-opus-4-20250514',
163 + ];
164 +
165 + return in_array($model, $retired, true) ? 'claude-sonnet-5' : $model;
166 + }
167 +
168 + /**
169 + * Whether the given model rejects sampling params (temperature/top_p/top_k).
170 + *
171 + * Anthropic removed these on Opus 4.7+, Opus 5, Sonnet 5, and Fable 5 —
172 + * including any date-suffixed or "-latest" alias of them — so they must be
173 + * omitted from the request body or the API returns a 400.
174 + *
175 + * Every generate_* method below sends a temperature, so a model missing from
176 + * this list fails on its first real call rather than at save time. `claude-opus-5`
177 + * was absent while being offered in the UI, which made the flagship model
178 + * unusable (#572).
179 + *
180 + * @param string $model Model ID
181 + * @return bool
182 + */
183 + private function model_rejects_sampling_params(string $model): bool {
184 + foreach (['claude-opus-4-7', 'claude-opus-4-8', 'claude-opus-5', 'claude-sonnet-5', 'claude-fable-5', 'claude-mythos-5'] as $prefix) {
185 + if (strpos($model, $prefix) === 0) {
186 + return true;
187 + }
188 + }
189 + return false;
190 + }
125 191
126 192 /**
127 193 * Generate SEO metadata
128 194 *
@@ -136,9 +202,10 @@
136 202 $content_type = $options['content_type'] ?? 'blog_post';
137 203 $tone = $options['tone'] ?? 'professional';
138 204
139 205 $prompt_builder = $this->get_prompt_builder();
140 - $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'claude');
206 + $language = is_string($options['language'] ?? null) ? $options['language'] : '';
207 + $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'claude', $language);
141 208
142 209 $response = $this->generate_completion($prompt, [
143 210 'max_tokens' => 500,
144 211 'temperature' => 0.3,
@@ -176,8 +243,145 @@
176 243 return $this->model;
177 244 }
178 245
179 246 /**
247 + * Output-token ceiling per model family, longest prefix wins.
248 + *
249 + * Matched by prefix so a dated snapshot (`claude-haiku-4-5-20251001`) and a
250 + * point release (`claude-fable-5-1`) resolve to their family. Order matters
251 + * only in that lookup walks longest-first, which is what keeps
252 + * `claude-fable-5-1` from matching `claude-fable-5`.
253 + *
254 + * @since 2.7.0
255 + * @var array<string, int>
256 + */
257 + private const MODEL_OUTPUT_LIMITS = [
258 + // 128K output.
259 + 'claude-fable-5-1' => 128000,
260 + 'claude-fable-5' => 128000,
261 + 'claude-mythos-5-1' => 128000,
262 + 'claude-mythos-5' => 128000,
263 + 'claude-opus-5' => 128000,
264 + 'claude-opus-4-8' => 128000,
265 + 'claude-opus-4-7' => 128000,
266 + 'claude-opus-4-6' => 128000,
267 + 'claude-sonnet-5' => 128000,
268 + 'claude-sonnet-4-6' => 128000,
269 + // 64K output.
270 + 'claude-haiku-4-5' => 64000,
271 + ];
272 +
273 + /**
274 + * Upper bound per use case, applied after the percentage.
275 + *
276 + * Two reasons these exist rather than letting the percentage run against a
277 + * 128K ceiling.
278 + *
279 + * Requests here are a single blocking HTTP call with a 120s timeout and no
280 + * streaming, so 0.9 x 128000 would risk running past the timeout instead of
281 + * returning — trading a truncation failure for a timeout failure. 16000
282 + * leaves room for the brief's JSON plus reasoning tokens while staying
283 + * answerable; raise it only alongside streaming.
284 + *
285 + * And correcting the ceiling would otherwise inflate every other use case
286 + * as a side effect — seo_metadata would jump from ~1,229 tokens to ~19,200
287 + * purely because this bug was fixed. Metadata generation already works, so
288 + * it keeps its cost profile (#665).
289 + *
290 + * @since 2.7.0
291 + * @var array<string, int>
292 + */
293 + private const USE_CASE_TOKEN_CAPS = [
294 + 'content_brief' => 16000,
295 + 'llms_txt' => 16000,
296 + 'analysis' => 8000,
297 + 'seo_metadata' => 4000,
298 + 'optimization' => 4000,
299 + 'default' => 4000,
300 + ];
301 +
302 + /**
303 + * Ceiling for a model this table does not know.
304 + *
305 + * The previous behaviour for every model, kept for older and unrecognised
306 + * ones: 8192 is accepted without an extended-output beta header, so it is
307 + * the safe answer when we cannot identify the family.
308 + *
309 + * @since 2.7.0
310 + * @var int
311 + */
312 + private const FALLBACK_OUTPUT_LIMIT = 8192;
313 +
314 + /**
315 + * Maximum completion (output) tokens accepted for a single Claude request.
316 + *
317 + * This returned a flat 8192 for every model and ignored $model entirely, so
318 + * Content Brief was capped at a fraction of the available budget and
319 + * truncated before its structured JSON completed — on every Claude model,
320 + * every time. Current models also emit reasoning tokens from the same
321 + * output budget, which is why it failed so reliably rather than
322 + * intermittently (#665).
323 + *
324 + * @param string $model Model ID.
325 + * @return int Maximum output tokens.
326 + */
327 + private function get_max_completion_tokens(string $model): int {
328 + $model = strtolower(trim($model));
329 +
330 + if ('' === $model) {
331 + return self::FALLBACK_OUTPUT_LIMIT;
332 + }
333 +
334 + $limits = self::MODEL_OUTPUT_LIMITS;
335 +
336 + // Longest prefix first, so a point release never matches the shorter
337 + // family id that is a prefix of it.
338 + uksort(
339 + $limits,
340 + static function (string $a, string $b): int {
341 + return strlen($b) <=> strlen($a);
342 + }
343 + );
344 +
345 + foreach ($limits as $prefix => $limit) {
346 + if (0 === strpos($model, $prefix)) {
347 + return $limit;
348 + }
349 + }
350 +
351 + return self::FALLBACK_OUTPUT_LIMIT;
352 + }
353 +
354 + /**
355 + * Recommended output-token budget for a given use case.
356 + *
357 + * Mirrors the other clients so the Content Brief generator no longer falls
358 + * back to a hardcoded, model-blind budget for Claude (issue #287). Each
359 + * value is a fraction of the model's completion ceiling.
360 + *
361 + * @param string $use_case e.g. 'content_brief', 'seo_metadata', 'analysis'.
362 + * @return int Recommended max output tokens.
363 + */
364 + public function get_recommended_tokens(string $use_case): int {
365 + $max_tokens = $this->get_max_completion_tokens($this->model);
366 +
367 + $recommendations = [
368 + 'content_brief' => 0.9, // Comprehensive brief incl. a full article body.
369 + 'seo_metadata' => 0.15,
370 + 'analysis' => 0.25,
371 + 'llms_txt' => 0.5,
372 + 'optimization' => 0.15,
373 + ];
374 + $percentage = $recommendations[$use_case] ?? 0.15;
375 +
376 + $budget = (int) ($max_tokens * $percentage);
377 +
378 + $cap = self::USE_CASE_TOKEN_CAPS[$use_case] ?? self::USE_CASE_TOKEN_CAPS['default'];
379 +
380 + return max(1, min($budget, $cap));
381 + }
382 +
383 + /**
180 384 * Test API connection
181 385 *
182 386 * @return bool True if connection successful
183 387 */
@@ -199,8 +403,14 @@
199 403 * @return array Response data
200 404 * @throws \Exception If request fails
201 405 */
202 406 private function make_request(string $endpoint, array $body = []): array {
407 + // The user's daily ceiling and kill switch are enforced here, at the
408 + // one place every outbound Claude call passes through, so no feature
409 + // path can bypass them by forgetting to ask first (#448).
410 + Spend_Guard::guard();
411 + Spend_Guard::record();
412 +
203 413 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
204 414
205 415 $args = [
206 416 'timeout' => $this->timeout,
@@ -212,34 +422,108 @@
212 422 ],
213 423 'method' => 'POST',
214 424 'body' => wp_json_encode($body),
215 425 ];
216 -
217 - $response = wp_remote_request($url, $args);
218 -
426 +
427 + $response = $this->request_with_retry($url, $args);
428 +
219 429 if (is_wp_error($response)) {
220 430 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
221 431 }
222 -
432 +
223 433 $status_code = wp_remote_retrieve_response_code($response);
224 434 $response_body = wp_remote_retrieve_body($response);
225 -
435 +
226 436 if ($status_code >= 400) {
227 437 $error_data = json_decode($response_body, true);
228 438 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
229 439 throw new \Exception(sprintf('Claude API error (%d): %s', (int) $status_code, esc_html($error_message)));
230 440 }
231 -
441 +
232 442 $data = json_decode($response_body, true);
233 -
443 +
234 444 if (json_last_error() !== JSON_ERROR_NONE) {
235 445 throw new \Exception('Invalid JSON response from Claude API');
236 446 }
237 -
447 +
448 + // A valid-but-scalar body (null/number/string from a proxy/gateway on a
449 + // 2xx) would violate this method's : array return type; reject it here so
450 + // it surfaces as a catchable \Exception, not an uncatchable TypeError.
451 + if (!is_array($data)) {
452 + throw new \Exception('Unexpected non-array response from Claude API');
453 + }
454 +
238 455 return $data;
239 456 }
240 457
241 458 /**
459 + * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
460 + * per the plugin's retry settings, honoring a Retry-After header when given.
461 + *
462 + * @param string $url Request URL
463 + * @param array $args wp_remote_request arguments
464 + * @return array|\WP_Error Final response (or last error after retries)
465 + */
466 + private function request_with_retry(string $url, array $args) {
467 + $settings = \ThinkRank\Core\Settings::instance();
468 + $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
469 + $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
470 +
471 + $response = null;
472 + for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
473 + // Keep PHP alive for the whole blocking call (see method docblock).
474 + $this->raise_request_time_limit();
475 +
476 + $response = wp_remote_request($url, $args);
477 +
478 + $is_transient = false;
479 + $retry_after = 0;
480 + if (is_wp_error($response)) {
481 + // A client-side timeout means the work genuinely needs longer
482 + // than the budget we allowed; re-running the identical prompt,
483 + // model and budget just times out again and multiplies the
484 + // wait (issue #288). Do not retry a timeout. Other WP_Error
485 + // results — DNS, connection refused, TLS — stay retryable.
486 + $is_transient = !$this->is_timeout_error($response);
487 + } else {
488 + $status = wp_remote_retrieve_response_code($response);
489 + if (429 === $status || $status >= 500) {
490 + $is_transient = true;
491 + $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
492 + }
493 + }
494 +
495 + if (!$is_transient || $attempt === $max_attempts) {
496 + break;
497 + }
498 +
499 + $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
500 + sleep($delay);
501 + }
502 +
503 + return $response;
504 + }
505 +
506 + /**
507 + * Give PHP enough execution time to outlive a blocking AI HTTP request.
508 + *
509 + * The provider call blocks for up to $this->timeout seconds, but the web
510 + * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
511 + * fatally terminates the script mid-request (inside the cURL transport),
512 + * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
513 + * before each attempt keeps the script alive for the full call; PHP-FPM's
514 + * request_terminate_timeout still caps the absolute maximum. No-op when
515 + * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
516 + *
517 + * @return void
518 + */
519 + private function raise_request_time_limit(): void {
520 + if (function_exists('set_time_limit')) {
521 + @set_time_limit($this->timeout + 45); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- set_time_limit() warns when disabled by host policy; the guard is intentional.
522 + }
523 + }
524 +
525 + /**
242 526 * Parse SEO response from Claude
243 527 *
244 528 * @param array $response Claude response
245 529 * @return array Parsed metadata
@@ -281,9 +565,9 @@
281 565 'description' => sanitize_text_field($metadata['description']),
282 566 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
283 567 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
284 568 'generated_at' => current_time('mysql'),
285 - 'tokens_used' => $response['usage']['input_tokens'] + $response['usage']['output_tokens'] ?? 0,
569 + 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
286 570 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
287 571 ];
288 572 }
289 573