| @@ -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 | |