| @@ -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 | /** |
| @@ -915,8 +959,32 @@ | ||
| 915 | 959 | } |
| 916 | 960 | |
| 917 | 961 | return $normalized; |
| 918 | 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 | + | |
| 919 | 987 | private function analyze_competitor_urls(array $urls): string { |
| 920 | 988 | $analysis_results = []; |
| 921 | 989 | $failed_urls = []; |
| 922 | 990 | |
| @@ -924,9 +992,10 @@ | ||
| 924 | 992 | $urls = array_slice($urls, 0, 3); |
| 925 | 993 | |
| 926 | 994 | foreach ($urls as $url) { |
| 927 | 995 | $url = trim($url); |
| 928 | - if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) { | |
| 996 | + $fetch_url = $this->competitor_fetch_url($url); | |
| 997 | + if (null === $fetch_url) { | |
| 929 | 998 | $failed_urls[] = $url . " (invalid URL)"; |
| 930 | 999 | continue; |
| 931 | 1000 | } |
| 932 | 1001 | |
| @@ -932,14 +1001,14 @@ | ||
| 932 | 1001 | |
| 933 | 1002 | // SSRF guard: only fetch public http/https hosts. Blocks loopback, |
| 934 | 1003 | // link-local (cloud metadata), private and reserved ranges before any |
| 935 | 1004 | // request is made. |
| 936 | - if (!$this->is_safe_public_url($url)) { | |
| 1005 | + if (!$this->is_safe_public_url($fetch_url)) { | |
| 937 | 1006 | $failed_urls[] = $url . " (blocked: non-public host)"; |
| 938 | 1007 | continue; |
| 939 | 1008 | } |
| 940 | 1009 | |
| 941 | - $content_data = $this->scrape_competitor_content($url); | |
| 1010 | + $content_data = $this->scrape_competitor_content($fetch_url); | |
| 942 | 1011 | if ($content_data) { |
| 943 | 1012 | $analysis_results[] = $this->format_competitor_analysis($url, $content_data); |
| 944 | 1013 | } else { |
| 945 | 1014 | $failed_urls[] = $url . " (failed to scrape)"; |
| @@ -1185,9 +1254,9 @@ | ||
| 1185 | 1254 | // Add heading structure |
| 1186 | 1255 | if (!empty($content_data['headings'])) { |
| 1187 | 1256 | $analysis .= "\nCONTENT STRUCTURE:\n"; |
| 1188 | 1257 | foreach ($content_data['headings'] as $level => $headings) { |
| 1189 | - $analysis .= "- " . strtoupper($level) . " ({count}): " . implode(', ', array_slice($headings, 0, 3)); | |
| 1258 | + $analysis .= "- " . strtoupper($level) . " (" . count($headings) . "): " . implode(', ', array_slice($headings, 0, 3)); | |
| 1190 | 1259 | if (count($headings) > 3) { |
| 1191 | 1260 | $analysis .= "... (+" . (count($headings) - 3) . " more)"; |
| 1192 | 1261 | } |
| 1193 | 1262 | $analysis .= "\n"; |
| @@ -1269,8 +1338,20 @@ | ||
| 1269 | 1338 | if (false === $result) { |
| 1270 | 1339 | throw new \Exception('Failed to save content brief to database.'); |
| 1271 | 1340 | } |
| 1272 | 1341 | |
| 1342 | + /** | |
| 1343 | + * Fires after a content brief is persisted. | |
| 1344 | + * | |
| 1345 | + * Analytics listens to drop its cached overview so the brief counts | |
| 1346 | + * on the Usages page are not stale for a TTL. | |
| 1347 | + * | |
| 1348 | + * @since 2.2.1 | |
| 1349 | + * | |
| 1350 | + * @param int $brief_id Row id of the stored brief. | |
| 1351 | + */ | |
| 1352 | + do_action('thinkrank_content_brief_created', (int) $wpdb->insert_id); | |
| 1353 | + | |
| 1273 | 1354 | return $wpdb->insert_id; |
| 1274 | 1355 | } |
| 1275 | 1356 | |
| 1276 | 1357 | /** |
| @@ -1992,9 +2073,9 @@ | ||
| 1992 | 2073 | [ |
| 1993 | 2074 | 'user_id' => $user_id, |
| 1994 | 2075 | 'action' => $action, |
| 1995 | 2076 | 'tokens_used' => $tokens_used, |
| 1996 | - 'provider' => $this->settings->get('ai_provider', 'openai'), | |
| 2077 | + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE), | |
| 1997 | 2078 | 'post_id' => $post_id, |
| 1998 | 2079 | 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null, |
| 1999 | 2080 | 'created_at' => current_time('mysql'), |
| 2000 | 2081 | ], |
| @@ -1999,8 +2080,17 @@ | ||
| 1999 | 2080 | 'created_at' => current_time('mysql'), |
| 2000 | 2081 | ], |
| 2001 | 2082 | ['%d', '%s', '%d', '%s', '%d', '%s', '%s'] |
| 2002 | 2083 | ); |
| 2084 | + | |
| 2085 | + /** | |
| 2086 | + * Fires after an AI usage row is recorded. | |
| 2087 | + * | |
| 2088 | + * @since 2.2.1 | |
| 2089 | + * | |
| 2090 | + * @param int $user_id User the usage was recorded against. | |
| 2091 | + */ | |
| 2092 | + do_action('thinkrank_ai_usage_logged', $user_id); | |
| 2003 | 2093 | |
| 2004 | 2094 | return $wpdb->insert_id; |
| 2005 | 2095 | } |
| 2006 | 2096 | |