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