PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.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 All 57 releases
← All changes | includes/ai/class-content-brief-generator.php +75 -12 2.4.0 → 2.14.2 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 /**
@@ -921,8 +959,32 @@
921 959 }
922 960
923 961 return $normalized;
924 962 }
963 + /**
964 + * The URL to check and fetch for a competitor URL the user entered, or
965 + * null when it is not a valid URL.
966 + *
967 + * A competitor page on an internationalised domain, or with a Bengali or
968 + * Arabic slug, is a valid URL, so the syntax check is Url_Validator's.
969 + * What comes back is the ASCII form (punycode host, percent-encoded path),
970 + * and both the SSRF guard and the fetch use it: the guard cannot resolve
971 + * a Unicode host name, and it must check exactly the URL that is then
972 + * requested.
973 + *
974 + * @since 2.14.2
975 + *
976 + * @param string $url Trimmed URL as entered.
977 + * @return string|null ASCII URL, or null when invalid.
978 + */
979 + private function competitor_fetch_url(string $url): ?string {
980 + if ('' === $url || !\ThinkRank\Core\Url_Validator::is_valid($url)) {
981 + return null;
982 + }
983 +
984 + return \ThinkRank\Core\Url_Validator::to_ascii($url);
985 + }
986 +
925 987 private function analyze_competitor_urls(array $urls): string {
926 988 $analysis_results = [];
927 989 $failed_urls = [];
928 990
@@ -930,9 +992,10 @@
930 992 $urls = array_slice($urls, 0, 3);
931 993
932 994 foreach ($urls as $url) {
933 995 $url = trim($url);
934 - if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
996 + $fetch_url = $this->competitor_fetch_url($url);
997 + if (null === $fetch_url) {
935 998 $failed_urls[] = $url . " (invalid URL)";
936 999 continue;
937 1000 }
938 1001
@@ -938,14 +1001,14 @@
938 1001
939 1002 // SSRF guard: only fetch public http/https hosts. Blocks loopback,
940 1003 // link-local (cloud metadata), private and reserved ranges before any
941 1004 // request is made.
942 - if (!$this->is_safe_public_url($url)) {
1005 + if (!$this->is_safe_public_url($fetch_url)) {
943 1006 $failed_urls[] = $url . " (blocked: non-public host)";
944 1007 continue;
945 1008 }
946 1009
947 - $content_data = $this->scrape_competitor_content($url);
1010 + $content_data = $this->scrape_competitor_content($fetch_url);
948 1011 if ($content_data) {
949 1012 $analysis_results[] = $this->format_competitor_analysis($url, $content_data);
950 1013 } else {
951 1014 $failed_urls[] = $url . " (failed to scrape)";
@@ -1191,9 +1254,9 @@
1191 1254 // Add heading structure
1192 1255 if (!empty($content_data['headings'])) {
1193 1256 $analysis .= "\nCONTENT STRUCTURE:\n";
1194 1257 foreach ($content_data['headings'] as $level => $headings) {
1195 - $analysis .= "- " . strtoupper($level) . " ({count}): " . implode(', ', array_slice($headings, 0, 3));
1258 + $analysis .= "- " . strtoupper($level) . " (" . count($headings) . "): " . implode(', ', array_slice($headings, 0, 3));
1196 1259 if (count($headings) > 3) {
1197 1260 $analysis .= "... (+" . (count($headings) - 3) . " more)";
1198 1261 }
1199 1262 $analysis .= "\n";