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-content-brief-generator.php +46 -8 2.3.0 → 2.9.0 View file →
@@ -153,11 +153,31 @@
153 153 if ($api_key) {
154 154 $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
155 155 $this->ai_client = new OpenRouter_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
156 156 }
157 + } elseif ($provider === 'openai_compatible') {
158 + // Same client as OpenAI, different host — and the key is optional,
159 + // so the URL and model id are what gate it (#721). The user's own
160 + // timeout applies: a local model writing a brief on CPU is slow,
161 + // and the setting exists for exactly that.
162 + $base_url = (string) $this->settings->get('openai_compatible_base_url', '');
163 + $model = trim((string) $this->settings->get('openai_compatible_model', ''));
164 + if ('' !== $base_url && '' !== $model) {
165 + $this->ai_client = new OpenAI_Client(
166 + (string) $this->settings->get('openai_compatible_api_key', ''),
167 + $model,
168 + (int) $this->settings->get('openai_compatible_timeout', Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
169 + $base_url
170 + );
171 + $this->ai_client->set_json_mode((bool) $this->settings->get('openai_compatible_json_mode', false));
172 + }
157 173 }
158 174
159 175 if (!$this->ai_client) {
176 + if ('openai_compatible' === $provider) {
177 + throw new \Exception('Please set the base URL and model id for your OpenAI-compatible endpoint in ThinkRank settings.');
178 + }
179 +
160 180 throw new \Exception('Please configure your AI provider API key in ThinkRank settings.');
161 181 }
162 182 }
163 183
@@ -185,8 +205,10 @@
185 205 } elseif ($provider === 'gemini') {
186 206 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
187 207 } elseif ($provider === 'openrouter') {
188 208 return $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
209 + } elseif ($provider === 'openai_compatible') {
210 + return (string) $this->settings->get('openai_compatible_model', '');
189 211 } else {
190 212 return $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
191 213 }
192 214 }
@@ -199,9 +221,9 @@
199 221 * costliest configuration, where billed reasoning tokens (drawn from the
200 222 * same budget) are spent before any visible output (issue #286).
201 223 *
202 224 * A brief is a structured planning task, so 'low' is a provisional middle
203 - * ground — Brand Visibility uses 'minimal' for quick consumer-style answers.
225 + * ground between 'minimal' and the model's default.
204 226 * The level is filterable so a site can trade latency for more reasoning;
205 227 * returning '' opts out entirely and lets the model use its default effort.
206 228 * Only the GPT-5 family consumes this — o1/o3, gpt-4o and the non-OpenAI
207 229 * clients ignore an unrecognised option key.
@@ -245,9 +267,9 @@
245 267 */
246 268 private function extract_token_usage(array $ai_response): int {
247 269 $provider = $this->get_current_provider();
248 270
249 - if ($provider === 'openai' || $provider === 'openrouter') {
271 + if ($provider === 'openai' || $provider === 'openrouter' || $provider === 'openai_compatible') {
250 272 // OpenAI-compatible format: response['usage']['total_tokens']
251 273 return (int) ($ai_response['usage']['total_tokens'] ?? 0);
252 274 } elseif ($provider === 'claude') {
253 275 // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens']
@@ -347,8 +369,11 @@
347 369 // max_completion_tokens internally. Temperature is intentionally
348 370 // omitted: every client defaults it to 0.7, and reasoning models
349 371 // reject it outright, so passing it here was misleading no-op.
350 372 'max_tokens' => $max_tokens,
373 + // The brief is one JSON object. Only a compatible endpoint
374 + // with JSON mode on reads this; every other client ignores it.
375 + 'json_object' => true,
351 376 ];
352 377 if ('' !== $reasoning_effort) {
353 378 $completion_options['reasoning_effort'] = $reasoning_effort;
354 379 }
@@ -360,9 +385,9 @@
360 385 // truncation) BEFORE attempting text extraction. Otherwise a
361 386 // refusal — which OpenAI returns as HTTP 200 with content=null —
362 387 // slips past every isset() branch and gets serialized into the
363 388 // brief body instead of being reported to the user.
364 - $this->guard_against_non_answer($ai_response);
389 + $this->guard_against_non_answer($ai_response, $max_tokens);
365 390
366 391 // Extract text content from AI response
367 392 $ai_text = '';
368 393
@@ -510,12 +535,13 @@
510 535 * don't catch them here they fall through to the "unexpected format" path
511 536 * (or, historically, were serialized into the brief body). All messages
512 537 * start with "The AI " so the outer catch passes them through unchanged.
513 538 *
514 - * @param mixed $ai_response Raw response from the AI client.
539 + * @param mixed $ai_response Raw response from the AI client.
540 + * @param int $requested_tokens The max_tokens this request asked for; 0 when unknown.
515 541 * @throws \Exception If the response is a refusal, policy block, or truncation.
516 542 */
517 - private function guard_against_non_answer($ai_response): void {
543 + private function guard_against_non_answer($ai_response, int $requested_tokens = 0): void {
518 544 if (!is_array($ai_response)) {
519 545 return;
520 546 }
521 547
@@ -535,10 +561,22 @@
535 561 }
536 562 if ('content_filter' === $finish) {
537 563 throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
538 564 }
565 + // A self-hosted server can stop short of max_tokens because the
566 + // prompt and the answer together filled its context window
567 + // (Ollama loads models at 4096 by default). A bigger output budget
568 + // cannot fix that, so say what can.
569 + $completion_tokens = (int) ($ai_response['usage']['completion_tokens'] ?? 0);
570 + if ('length' === $finish && $requested_tokens > 0 && $completion_tokens > 0 && $completion_tokens < $requested_tokens) {
571 + throw new \Exception(esc_html(sprintf(
572 + 'The AI stopped after %1$d tokens, short of the %2$d allowed, because the server ran out of context window before finishing the brief. Raise the context length on your AI server (for Ollama, set OLLAMA_CONTEXT_LENGTH to 16384 or more) and try again.',
573 + $completion_tokens,
574 + $requested_tokens
575 + )));
576 + }
539 577 if ('length' === $finish) {
540 - throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
578 + throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.');
541 579 }
542 580 }
543 581
544 582 // --- Claude (Messages) ---
@@ -547,9 +585,9 @@
547 585 if ('refusal' === $stop_reason) {
548 586 throw new \Exception('The AI declined to generate this brief for this topic. Try a different topic or less sensitive keywords.');
549 587 }
550 588 if ('max_tokens' === $stop_reason) {
551 - throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
589 + throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.');
552 590 }
553 591 }
554 592
555 593 // --- Gemini ---
@@ -567,9 +605,9 @@
567 605 if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
568 606 throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
569 607 }
570 608 if ('MAX_TOKENS' === $gemini_finish) {
571 - throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
609 + throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.');
572 610 }
573 611 }
574 612
575 613 /**