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 +71 -8 2.7.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 }
@@ -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,8 +561,20 @@
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 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 }
@@ -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";