| @@ -243,19 +243,113 @@ | ||
| 243 | 243 | return $this->model; |
| 244 | 244 | } |
| 245 | 245 | |
| 246 | 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 | + /** | |
| 247 | 315 | * Maximum completion (output) tokens accepted for a single Claude request. |
| 248 | 316 | * |
| 249 | - * 8192 is accepted by every current Claude model without the extended-output | |
| 250 | - * beta header, so it is a safe per-request ceiling. Kept as a method (rather | |
| 251 | - * than a constant) to mirror the other clients and allow per-model tuning. | |
| 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). | |
| 252 | 323 | * |
| 253 | - * @param string $model Model ID (reserved for future per-model limits). | |
| 324 | + * @param string $model Model ID. | |
| 254 | 325 | * @return int Maximum output tokens. |
| 255 | 326 | */ |
| 256 | 327 | private function get_max_completion_tokens(string $model): int { |
| 257 | - return 8192; | |
| 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; | |
| 258 | 352 | } |
| 259 | 353 | |
| 260 | 354 | /** |
| 261 | 355 | * Recommended output-token budget for a given use case. |
| @@ -278,9 +372,13 @@ | ||
| 278 | 372 | 'optimization' => 0.15, |
| 279 | 373 | ]; |
| 280 | 374 | $percentage = $recommendations[$use_case] ?? 0.15; |
| 281 | 375 | |
| 282 | - return (int) ($max_tokens * $percentage); | |
| 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)); | |
| 283 | 381 | } |
| 284 | 382 | |
| 285 | 383 | /** |
| 286 | 384 | * Test API connection |