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 +77 -12 2.1.1 → 2.9.0 View file →
@@ -127,9 +127,9 @@
127 127 *
128 128 * @throws \Exception On failure.
129 129 */
130 130 private function init_ai_client(): void {
131 - $provider = $this->settings->get('ai_provider', 'openai');
131 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
132 132
133 133 if ($provider === 'openai') {
134 134 $api_key = $this->settings->get('openai_api_key');
135 135 if ($api_key) {
@@ -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
@@ -172,9 +192,15 @@
172 192 return $this->ai_client->get_model();
173 193 }
174 194
175 195 // Fallback to settings
176 - $provider = $this->settings->get('ai_provider', 'openai');
196 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
197 + if (Settings::AI_PROVIDER_NONE === $provider) {
198 + // No provider chosen, so there is no model to name. Reporting the
199 + // OpenAI default here would attribute output to a provider the site
200 + // never selected (#572).
201 + return '';
202 + }
177 203 if ($provider === 'claude') {
178 204 return $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
179 205 } elseif ($provider === 'gemini') {
180 206 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
@@ -179,8 +205,10 @@
179 205 } elseif ($provider === 'gemini') {
180 206 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
181 207 } elseif ($provider === 'openrouter') {
182 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', '');
183 211 } else {
184 212 return $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
185 213 }
186 214 }
@@ -193,9 +221,9 @@
193 221 * costliest configuration, where billed reasoning tokens (drawn from the
194 222 * same budget) are spent before any visible output (issue #286).
195 223 *
196 224 * A brief is a structured planning task, so 'low' is a provisional middle
197 - * ground — Brand Visibility uses 'minimal' for quick consumer-style answers.
225 + * ground between 'minimal' and the model's default.
198 226 * The level is filterable so a site can trade latency for more reasoning;
199 227 * returning '' opts out entirely and lets the model use its default effort.
200 228 * Only the GPT-5 family consumes this — o1/o3, gpt-4o and the non-OpenAI
201 229 * clients ignore an unrecognised option key.
@@ -227,9 +255,9 @@
227 255 *
228 256 * @return string Current provider name
229 257 */
230 258 private function get_current_provider(): string {
231 - return $this->settings->get('ai_provider', 'openai');
259 + return $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
232 260 }
233 261
234 262 /**
235 263 * Extract token usage from AI response
@@ -239,9 +267,9 @@
239 267 */
240 268 private function extract_token_usage(array $ai_response): int {
241 269 $provider = $this->get_current_provider();
242 270
243 - if ($provider === 'openai' || $provider === 'openrouter') {
271 + if ($provider === 'openai' || $provider === 'openrouter' || $provider === 'openai_compatible') {
244 272 // OpenAI-compatible format: response['usage']['total_tokens']
245 273 return (int) ($ai_response['usage']['total_tokens'] ?? 0);
246 274 } elseif ($provider === 'claude') {
247 275 // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens']
@@ -341,8 +369,11 @@
341 369 // max_completion_tokens internally. Temperature is intentionally
342 370 // omitted: every client defaults it to 0.7, and reasoning models
343 371 // reject it outright, so passing it here was misleading no-op.
344 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,
345 376 ];
346 377 if ('' !== $reasoning_effort) {
347 378 $completion_options['reasoning_effort'] = $reasoning_effort;
348 379 }
@@ -354,9 +385,9 @@
354 385 // truncation) BEFORE attempting text extraction. Otherwise a
355 386 // refusal — which OpenAI returns as HTTP 200 with content=null —
356 387 // slips past every isset() branch and gets serialized into the
357 388 // brief body instead of being reported to the user.
358 - $this->guard_against_non_answer($ai_response);
389 + $this->guard_against_non_answer($ai_response, $max_tokens);
359 390
360 391 // Extract text content from AI response
361 392 $ai_text = '';
362 393
@@ -504,12 +535,13 @@
504 535 * don't catch them here they fall through to the "unexpected format" path
505 536 * (or, historically, were serialized into the brief body). All messages
506 537 * start with "The AI " so the outer catch passes them through unchanged.
507 538 *
508 - * @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.
509 541 * @throws \Exception If the response is a refusal, policy block, or truncation.
510 542 */
511 - private function guard_against_non_answer($ai_response): void {
543 + private function guard_against_non_answer($ai_response, int $requested_tokens = 0): void {
512 544 if (!is_array($ai_response)) {
513 545 return;
514 546 }
515 547
@@ -529,10 +561,22 @@
529 561 }
530 562 if ('content_filter' === $finish) {
531 563 throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
532 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 + }
533 577 if ('length' === $finish) {
534 - 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.');
535 579 }
536 580 }
537 581
538 582 // --- Claude (Messages) ---
@@ -541,9 +585,9 @@
541 585 if ('refusal' === $stop_reason) {
542 586 throw new \Exception('The AI declined to generate this brief for this topic. Try a different topic or less sensitive keywords.');
543 587 }
544 588 if ('max_tokens' === $stop_reason) {
545 - 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.');
546 590 }
547 591 }
548 592
549 593 // --- Gemini ---
@@ -561,9 +605,9 @@
561 605 if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
562 606 throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
563 607 }
564 608 if ('MAX_TOKENS' === $gemini_finish) {
565 - 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.');
566 610 }
567 611 }
568 612
569 613 /**
@@ -1269,8 +1313,20 @@
1269 1313 if (false === $result) {
1270 1314 throw new \Exception('Failed to save content brief to database.');
1271 1315 }
1272 1316
1317 + /**
1318 + * Fires after a content brief is persisted.
1319 + *
1320 + * Analytics listens to drop its cached overview so the brief counts
1321 + * on the Usages page are not stale for a TTL.
1322 + *
1323 + * @since 2.2.1
1324 + *
1325 + * @param int $brief_id Row id of the stored brief.
1326 + */
1327 + do_action('thinkrank_content_brief_created', (int) $wpdb->insert_id);
1328 +
1273 1329 return $wpdb->insert_id;
1274 1330 }
1275 1331
1276 1332 /**
@@ -1992,9 +2048,9 @@
1992 2048 [
1993 2049 'user_id' => $user_id,
1994 2050 'action' => $action,
1995 2051 'tokens_used' => $tokens_used,
1996 - 'provider' => $this->settings->get('ai_provider', 'openai'),
2052 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1997 2053 'post_id' => $post_id,
1998 2054 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1999 2055 'created_at' => current_time('mysql'),
2000 2056 ],
@@ -1999,8 +2055,17 @@
1999 2055 'created_at' => current_time('mysql'),
2000 2056 ],
2001 2057 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
2002 2058 );
2059 +
2060 + /**
2061 + * Fires after an AI usage row is recorded.
2062 + *
2063 + * @since 2.2.1
2064 + *
2065 + * @param int $user_id User the usage was recorded against.
2066 + */
2067 + do_action('thinkrank_ai_usage_logged', $user_id);
2003 2068
2004 2069 return $wpdb->insert_id;
2005 2070 }
2006 2071