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