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/core/class-settings.php +150 -2 2.7.02.9.0 View file →
@@ -48,8 +48,19 @@
48 48 const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash';
49 49 const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-4o-mini';
50 50
51 51 /**
52 + * Default request timeout for the OpenAI-compatible provider, in seconds.
53 + *
54 + * Deliberately higher than the 120s the hosted providers get: a local model
55 + * on CPU routinely takes minutes for a content brief, and a timeout there
56 + * is never retried (see OpenAI_Client::request_with_retry()).
57 + *
58 + * @since 2.8.0
59 + */
60 + const DEFAULT_OPENAI_COMPATIBLE_TIMEOUT = 120;
61 +
62 + /**
52 63 * Canonical default author-archive templates.
53 64 *
54 65 * Same single-source-of-truth rule as the model constants above: the
55 66 * defaults array, the REST endpoint, the get-settings ability and
@@ -67,9 +78,9 @@
67 78 *
68 79 * @since 2.2.0
69 80 * @var string[]
70 81 */
71 - public const SUPPORTED_AI_PROVIDERS = ['openai', 'claude', 'gemini', 'openrouter'];
82 + public const SUPPORTED_AI_PROVIDERS = ['openai', 'claude', 'gemini', 'openrouter', 'openai_compatible'];
72 83
73 84 /**
74 85 * The stored value meaning "the user has not chosen a provider yet".
75 86 *
@@ -97,8 +108,40 @@
97 108 public static function selectable_ai_providers(): array {
98 109 return array_merge([self::AI_PROVIDER_NONE], self::SUPPORTED_AI_PROVIDERS);
99 110 }
100 111
112 + /**
113 + * Is the selected AI provider configured well enough to run a request?
114 + *
115 + * One answer for the whole plugin. The admin menu notice, the metabox
116 + * "Generate with AI" button and every generator used to ask their own
117 + * version of this as an inline OR over the API key settings, which the
118 + * OpenAI-compatible provider invalidates: a local Ollama or LM Studio
119 + * server has no key and is configured by base URL + model id (#721).
120 + *
121 + * Provider-aware on purpose. A stored key for a provider the site did not
122 + * select never made AI features work, so counting it only produced enabled
123 + * buttons that fail on click.
124 + *
125 + * @since 2.8.0
126 + *
127 + * @return bool
128 + */
129 + public function has_ai_provider_configured(): bool {
130 + $provider = (string) $this->get('ai_provider', self::AI_PROVIDER_NONE);
131 +
132 + if (self::AI_PROVIDER_NONE === $provider) {
133 + return false;
134 + }
135 +
136 + if ('openai_compatible' === $provider) {
137 + return '' !== trim((string) $this->get('openai_compatible_base_url', ''))
138 + && '' !== trim((string) $this->get('openai_compatible_model', ''));
139 + }
140 +
141 + return !empty($this->get($provider . '_api_key'));
142 + }
143 +
101 144 const DEFAULT_AUTHOR_ARCHIVES_TITLE = '%author_name% %separator% %site_title% %page%';
102 145 const DEFAULT_AUTHOR_ARCHIVES_META_DESC = 'Articles written by %author_name% on %site_title%';
103 146
104 147 /**
@@ -182,8 +225,18 @@
182 225 'gemini_api_key' => '',
183 226 'gemini_model' => self::DEFAULT_GEMINI_MODEL,
184 227 'openrouter_api_key' => '',
185 228 'openrouter_model' => self::DEFAULT_OPENROUTER_MODEL,
229 + // OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, Azure OpenAI,
230 + // Groq, a company gateway…). No default URL or model: this provider
231 + // does nothing until the administrator names a server (#721).
232 + 'openai_compatible_base_url' => '',
233 + 'openai_compatible_api_key' => '',
234 + 'openai_compatible_model' => '',
235 + 'openai_compatible_timeout' => self::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT,
236 + 'openai_compatible_supports_images' => false,
237 + 'openai_compatible_json_mode' => false,
238 + 'openai_compatible_price_per_million' => 0.0,
186 239 'max_tokens' => 1000,
187 240 'temperature' => 0.7,
188 241
189 242 // Google API Keys (encrypted)
@@ -205,11 +258,17 @@
205 258 'ga_analytics_data_stream_id' => '',
206 259
207 260 // Performance Settings
208 261 'cache_duration' => 3600,
209 - 'max_requests_per_minute' => 10,
262 + 'max_requests_per_minute' => 0,
210 263 'enable_logging' => true,
211 264
265 + // AI spend controls (#448). Both are neutral by default so an upgrade
266 + // changes nothing: 0 means no daily ceiling, and AI is not paused.
267 + // Enforced by ThinkRank\AI\Spend_Guard at the provider HTTP boundary.
268 + 'ai_daily_request_limit' => 0,
269 + 'ai_paused' => false,
270 +
212 271 // Integration Settings
213 272 'api_timeout' => 30,
214 273 'enable_rate_limiting' => true,
215 274 'auto_test_connections' => true,
@@ -298,8 +357,9 @@
298 357 'openai_api_key',
299 358 'claude_api_key',
300 359 'gemini_api_key',
301 360 'openrouter_api_key',
361 + 'openai_compatible_api_key',
302 362 // Google API Keys
303 363 'google_analytics_api_key',
304 364 'google_search_console_api_key',
305 365 'google_pagespeed_api_key',
@@ -317,8 +377,24 @@
317 377 * @return void
318 378 */
319 379 public function init(): void {
320 380 add_action('admin_init', [$this, 'register_settings']);
381 + // Every value below is read from THIS site's options, and the cache is
382 + // keyed by setting name alone. On multisite a switch_to_blog() leaves
383 + // the previous site's values sitting in it, so anything that switches
384 + // reads the wrong site's configuration (#516). Nothing in the plugin
385 + // switched blogs before the OAuth discovery resolver did, which is why
386 + // this had never bitten.
387 + //
388 + // Registered against the CLASS, not $this. init() runs on the object in
389 + // the component container, while every consumer reads through
390 + // Settings::instance() — and those are not always the same object: a
391 + // caller that resolves the singleton while load_components() is still
392 + // building the array gets a standalone instance, which is then memoized
393 + // for the rest of the request. Hooking $this there registers the flush
394 + // on an object nobody reads through, which is exactly the shape of bug
395 + // a source-scanning test cannot see.
396 + add_action('switch_blog', [self::class, 'flush_instance_cache']);
321 397 // Admin-only: a front-end pageview can never need this migration, and
322 398 // the marker is a non-autoloaded option, so hooking it unconditionally
323 399 // bought one dedicated query on every request for the life of the
324 400 // install (#588).
@@ -327,8 +403,35 @@
327 403 }
328 404 }
329 405
330 406 /**
407 + * Drop every memoized setting value.
408 + *
409 + * Called on `switch_blog` so a blog switch cannot serve the previous
410 + * site's configuration, and available to any caller that switches
411 + * explicitly. Cheap: the next read repopulates from the options cache.
412 + *
413 + * @since 2.9.0
414 + * @return void
415 + */
416 + public function flush_cache(): void {
417 + $this->cache = [];
418 + }
419 +
420 + /**
421 + * Drop the memo on the instance consumers actually read through.
422 + *
423 + * Resolved at fire time rather than registration time, so it always acts on
424 + * whatever Settings::instance() currently returns.
425 + *
426 + * @since 2.9.0
427 + * @return void
428 + */
429 + public static function flush_instance_cache(): void {
430 + self::instance()->flush_cache();
431 + }
432 +
433 + /**
331 434 * Clear the OpenAI selection that older versions seeded on activation.
332 435 *
333 436 * Changing the default only helps installs created after the change. Every
334 437 * site activated before it still carries `ai_provider = 'openai'` written by
@@ -771,10 +874,46 @@
771 874 case 'openai_api_key':
772 875 case 'claude_api_key':
773 876 case 'gemini_api_key':
774 877 case 'openrouter_api_key':
878 + case 'openai_compatible_api_key':
775 879 return sanitize_text_field($value);
776 880
881 + case 'openai_compatible_base_url':
882 + // Validation (scheme, SSRF guard) belongs to the write path that
883 + // can report a reason to the user; a value that never went
884 + // through it must not become a URL we fetch, so an invalid one
885 + // is stored as empty — which disables the provider — rather
886 + // than silently kept.
887 + $url = esc_url_raw(trim((string) $value));
888 + if ('' === $url) {
889 + return '';
890 + }
891 + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($url);
892 +
893 + return is_wp_error($validated) ? '' : $validated;
894 +
895 + case 'openai_compatible_timeout':
896 + // Below 10s nothing local ever finishes; above 600s PHP-FPM
897 + // kills the request first.
898 + return max(10, min(600, absint($value)));
899 +
900 + case 'openai_compatible_price_per_million':
901 + return max(0.0, (float) $value);
902 +
903 + case 'openai_compatible_supports_images':
904 + case 'openai_compatible_json_mode':
905 + // Each toggle decides whether we send a field (a vision
906 + // payload, response_format) a server may reject, so coerce the '0'/'false' a form
907 + // post can send rather than storing a truthy string (the
908 + // default: arm keeps strings as strings, and '0' is truthy to
909 + // nobody but PHP's loose rules).
910 + if (is_string($value)) {
911 + return !in_array(strtolower(trim($value)), ['', '0', 'false', 'no', 'off'], true);
912 + }
913 +
914 + return (bool) $value;
915 +
777 916 case 'ai_provider':
778 917 // sanitize_key() maps '' to '', which is AI_PROVIDER_NONE — the
779 918 // deliberate "no provider chosen" state, so it must survive here
780 919 // rather than being folded back into the default (#572).
@@ -787,8 +926,9 @@
787 926 case 'openai_model':
788 927 case 'claude_model':
789 928 case 'gemini_model':
790 929 case 'openrouter_model':
930 + case 'openai_compatible_model':
791 931 // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
792 932 // "openai/gpt-4o-mini") and users can enter custom models, so
793 933 // sanitize_key() would corrupt them — use text-field sanitizing.
794 934 return sanitize_text_field($value);
@@ -795,8 +935,9 @@
795 935
796 936 case 'max_tokens':
797 937 case 'cache_duration':
798 938 case 'max_requests_per_minute':
939 + case 'ai_daily_request_limit':
799 940 case 'seo_score_threshold':
800 941 case 'api_timeout':
801 942 case 'retry_attempts':
802 943 case 'data_retention_days':
@@ -801,8 +942,15 @@
801 942 case 'retry_attempts':
802 943 case 'data_retention_days':
803 944 case 'monitoring_frequency':
804 945 return absint($value);
946 +
947 + case 'ai_paused':
948 + // The kill switch arrives from REST as a real boolean, from a
949 + // form post as "1"/"0", and from WP-CLI as "true"/"false". The
950 + // default arm's sanitize_text_field() would turn "false" into a
951 + // truthy string and silently pause a site that asked to resume.
952 + return rest_sanitize_boolean($value);
805 953
806 954 case 'temperature':
807 955 return (float) $value;
808 956