| @@ -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 | } |