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 +155 -47 2.5.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,
@@ -231,37 +290,8 @@
231 290 'seo_score_threshold' => 70,
232 291 'enable_meta_generation' => true,
233 292 'enable_schema_markup' => true,
234 293
235 - // Auto AI Optimization (on-publish metadata fill; #248 P1)
236 - 'auto_ai_meta_enabled' => false,
237 - 'auto_ai_meta_post_types' => ['post'],
238 -
239 - // Brand Visibility v2. The brand profile drives question generation
240 - // and mention detection; per-platform keys let this feature query
241 - // several assistants without changing the site-wide AI provider.
242 - 'bv_brand_name' => '',
243 - 'bv_variants' => [],
244 - 'bv_location' => '',
245 - 'bv_category' => '',
246 - 'bv_description' => '',
247 - 'bv_competitors' => [],
248 - 'bv_queries' => [],
249 - 'bv_platforms' => ['chatgpt'],
250 - 'bv_samples' => 1,
251 - 'bv_key_chatgpt' => '',
252 - 'bv_key_gemini' => '',
253 - 'bv_key_claude' => '',
254 - 'bv_key_perplexity' => '',
255 - // Empty = use the platform's default model (see Brand_Visibility_Providers).
256 - 'bv_model_chatgpt' => '',
257 - 'bv_model_gemini' => '',
258 - 'bv_model_claude' => '',
259 - 'bv_model_perplexity' => '',
260 -
261 - // AI Brand Visibility (BYO-key checks; #248 P1)
262 - 'brand_visibility_queries' => [],
263 -
264 294 // Author Archives Settings
265 295 'author_archives_enabled' => true,
266 296 'author_archives_index' => true,
267 297 'author_archives_show_empty' => false,
@@ -296,16 +326,8 @@
296 326 // Integration Settings
297 327 'google_analytics_id' => '',
298 328 'search_console_property' => '',
299 329
300 - // GA4 Tracking Settings
301 - 'ga4_measurement_id' => '',
302 - 'ga4_auto_inject' => false,
303 - 'ga4_anonymize_ip' => false,
304 - 'ga4_exclude_admin' => false,
305 - 'ga4_tracking_verified' => false,
306 - 'ga4_last_verification' => '',
307 -
308 330 // SEO Analytics Settings
309 331 'seo_analytics_enabled' => false,
310 332 'seo_analytics_setup_completed' => false,
311 333 'seo_analytics_google_analytics_property_id' => '',
@@ -335,8 +357,9 @@
335 357 'openai_api_key',
336 358 'claude_api_key',
337 359 'gemini_api_key',
338 360 'openrouter_api_key',
361 + 'openai_compatible_api_key',
339 362 // Google API Keys
340 363 'google_analytics_api_key',
341 364 'google_search_console_api_key',
342 365 'google_pagespeed_api_key',
@@ -354,8 +377,24 @@
354 377 * @return void
355 378 */
356 379 public function init(): void {
357 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']);
358 397 // Admin-only: a front-end pageview can never need this migration, and
359 398 // the marker is a non-autoloaded option, so hooking it unconditionally
360 399 // bought one dedicated query on every request for the life of the
361 400 // install (#588).
@@ -364,8 +403,35 @@
364 403 }
365 404 }
366 405
367 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 + /**
368 434 * Clear the OpenAI selection that older versions seeded on activation.
369 435 *
370 436 * Changing the default only helps installs created after the change. Every
371 437 * site activated before it still carries `ai_provider = 'openai'` written by
@@ -808,10 +874,46 @@
808 874 case 'openai_api_key':
809 875 case 'claude_api_key':
810 876 case 'gemini_api_key':
811 877 case 'openrouter_api_key':
878 + case 'openai_compatible_api_key':
812 879 return sanitize_text_field($value);
813 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 +
814 916 case 'ai_provider':
815 917 // sanitize_key() maps '' to '', which is AI_PROVIDER_NONE — the
816 918 // deliberate "no provider chosen" state, so it must survive here
817 919 // rather than being folded back into the default (#572).
@@ -824,8 +926,9 @@
824 926 case 'openai_model':
825 927 case 'claude_model':
826 928 case 'gemini_model':
827 929 case 'openrouter_model':
930 + case 'openai_compatible_model':
828 931 // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
829 932 // "openai/gpt-4o-mini") and users can enter custom models, so
830 933 // sanitize_key() would corrupt them — use text-field sanitizing.
831 934 return sanitize_text_field($value);
@@ -832,8 +935,9 @@
832 935
833 936 case 'max_tokens':
834 937 case 'cache_duration':
835 938 case 'max_requests_per_minute':
939 + case 'ai_daily_request_limit':
836 940 case 'seo_score_threshold':
837 941 case 'api_timeout':
838 942 case 'retry_attempts':
839 943 case 'data_retention_days':
@@ -839,8 +943,15 @@
839 943 case 'data_retention_days':
840 944 case 'monitoring_frequency':
841 945 return absint($value);
842 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);
953 +
843 954 case 'temperature':
844 955 return (float) $value;
845 956
846 957 case 'dashboard_widgets':
@@ -879,15 +990,13 @@
879 990 case 'seo_analytics_report_schedule':
880 991 return sanitize_text_field($value);
881 992
882 993 case 'robots_txt_content':
883 - case 'bv_description':
884 994 // Multi-line content — sanitize_text_field() collapses newlines
885 995 // and would flatten the whole file onto a single line. set()
886 996 // routes every write through here, so a caller that chose
887 - // sanitize_textarea_field() itself (Brand_Visibility_Endpoint
888 - // does, for bv_description) is otherwise silently overridden
889 - // by the default: arm below (#587).
997 + // sanitize_textarea_field() itself is otherwise silently
998 + // overridden by the default: arm below (#587).
890 999 return sanitize_textarea_field($value);
891 1000
892 1001 default:
893 1002 if (is_bool($value)) {
@@ -896,12 +1005,11 @@
896 1005 return sanitize_text_field($value);
897 1006 } elseif (is_array($value)) {
898 1007 // Recurse rather than drop. Skipping nested members was
899 1008 // harmless while this ran only on the register_setting()
900 - // path, but set() now routes every write through here and
901 - // structured settings — bv_competitors is a list of
902 - // ['name','url'] maps, bv_queries a list of ['text','type']
903 - // — were being silently emptied on save.
1009 + // path, but set() now routes every write through here, and
1010 + // structured settings (lists of maps) were being silently
1011 + // emptied on save.
904 1012 return $this->sanitize_array_recursive($value);
905 1013 }
906 1014 return $value;
907 1015 }