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 +167 -7 1.28.0 → 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 */
@@ -160,17 +167,22 @@
160 167
161 168 /**
162 169 * Whether the given model rejects sampling params (temperature/top_p/top_k).
163 170 *
164 - * Anthropic removed these on Opus 4.7+, Sonnet 5, and Fable 5 — including any
165 - * date-suffixed or "-latest" alias of them — so they must be omitted from the
166 - * request body or the API returns a 400.
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.
167 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 + *
168 180 * @param string $model Model ID
169 181 * @return bool
170 182 */
171 183 private function model_rejects_sampling_params(string $model): bool {
172 - foreach (['claude-opus-4-7', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-fable-5', 'claude-mythos-5'] as $prefix) {
184 + foreach (['claude-opus-4-7', 'claude-opus-4-8', 'claude-opus-5', 'claude-sonnet-5', 'claude-fable-5', 'claude-mythos-5'] as $prefix) {
173 185 if (strpos($model, $prefix) === 0) {
174 186 return true;
175 187 }
176 188 }
@@ -231,8 +243,145 @@
231 243 return $this->model;
232 244 }
233 245
234 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 + /**
235 384 * Test API connection
236 385 *
237 386 * @return bool True if connection successful
238 387 */
@@ -254,8 +403,14 @@
254 403 * @return array Response data
255 404 * @throws \Exception If request fails
256 405 */
257 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 +
258 413 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
259 414
260 415 $args = [
261 416 'timeout' => $this->timeout,
@@ -322,9 +477,14 @@
322 477
323 478 $is_transient = false;
324 479 $retry_after = 0;
325 480 if (is_wp_error($response)) {
326 - $is_transient = true;
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);
327 487 } else {
328 488 $status = wp_remote_retrieve_response_code($response);
329 489 if (429 === $status || $status >= 500) {
330 490 $is_transient = true;