PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / trunk
MxChat – AI Chatbot & Content Generation for WordPress vtrunk
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-mxchat-model-liveness.php

class-mxchat-model-liveness.php in MxChat – AI Chatbot & Content Generation for WordPress trunk, at includes/class-mxchat-model-liveness.php

474 lines 19.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MxChat Model Liveness — daily check that the configured chat / content
4 * model still appears in its provider's public model listing.
5 *
6 * Plan: plan-mxchat-20260811-b65e8d. Claude Opus 4.1 retired 2026-08-05 and
7 * sat selectable for five days — any site configured on it had a silently
8 * dead chatbot until a human noticed. The hardcoded deprecation list +
9 * migration guard only help AFTER a release ships; this closes the notice
10 * gap itself by asking the provider directly, once a day.
11 *
12 * TWO STAGES, and the second one is why this works at all.
13 *
14 * The plan specified a listing-only comparison ("live listing beats a dated
15 * field"). Probed against real providers on 2026-08-13, that premise does not
16 * hold — a model listing is not authoritative about retirement in EITHER
17 * direction:
18 * - Anthropic DROPS live aliases: claude-opus-4-5 is absent from /v1/models
19 * and answers HTTP 200 perfectly.
20 * - xAI's listing is partial: grok-4-0709 is absent and answers 200.
21 * - OpenAI KEEPS dead ids: gpt-5.3-chat-latest is listed and 404s
22 * "model_deprecated" on use.
23 * A listing-only check would therefore have bannered every Anthropic-alias and
24 * xAI install on the planet — the exact false alarm the plan forbids.
25 *
26 * So:
27 * STAGE 1 (free) — is the id in the provider's listing? If yes, healthy;
28 * stop. This is the common case and costs zero tokens.
29 * STAGE 2 (rare) — if absent, that is a SUSPICION. Confirm with one 1-token
30 * inference call and flag ONLY on an explicit
31 * model-not-found. Anything else changes nothing.
32 *
33 * Known limitation, deliberately accepted: OpenAI's habit of keeping retired
34 * ids listed means stage 1 short-circuits and this check will NOT catch that
35 * shape. The reactive mxchat_model_access_notice (armed by the integrator when
36 * a real request fails) covers it, one visitor later.
37 *
38 * Design constraints (from the plan, all preserved):
39 * - One listing request per provider per day, only for providers actually
40 * in use, skipped entirely when no key is stored.
41 * - Fail-open EVERYWHERE: HTTP error, timeout, non-200, unrecognized JSON,
42 * empty listing, a truncated listing, or an inconclusive probe — all keep
43 * the last known state. This feature must never produce a false "your bot
44 * is broken" banner on network noise.
45 * - Inform only. No auto-switching — changing the model stays a human
46 * action (the e46b8f migration guard owns known retirements at upgrade).
47 *
48 * Complementary to mxchat_model_access_notice (the REACTIVE one-shot error
49 * notice the integrator arms when a live chat request already failed): this
50 * check is PROACTIVE — it warns before a visitor ever hits the dead model.
51 */
52
53 if (!defined('ABSPATH')) {
54 exit;
55 }
56
57 class MxChat_Model_Liveness {
58
59 const CRON_HOOK = 'mxchat_model_liveness_check';
60 const OPTION = 'mxchat_model_liveness';
61
62 /** Providers with a supported public model-listing endpoint. */
63 private static $checkable_providers = array('openai', 'claude', 'gemini', 'xai');
64
65 public static function init() {
66 add_action(self::CRON_HOOK, array(__CLASS__, 'run_check'));
67
68 if (!wp_next_scheduled(self::CRON_HOOK)) {
69 wp_schedule_event(time() + 2 * HOUR_IN_SECONDS, 'daily', self::CRON_HOOK);
70 }
71
72 if (is_admin()) {
73 add_action('admin_notices', array(__CLASS__, 'render_notice'));
74 }
75 }
76
77 /* ---------------------------------------------------------------------
78 * The daily check
79 * ------------------------------------------------------------------ */
80
81 public static function run_check() {
82 $options = get_option('mxchat_options', array());
83 if (!is_array($options)) {
84 $options = array();
85 }
86
87 $configured = self::configured_models($options);
88
89 $state = get_option(self::OPTION, array());
90 $missing = (is_array($state) && isset($state['missing']) && is_array($state['missing']))
91 ? $state['missing']
92 : array();
93
94 // A flag for a model that's no longer configured is moot — drop it.
95 foreach (array_keys($missing) as $flagged) {
96 if (!in_array((string) $flagged, $configured, true)) {
97 unset($missing[$flagged]);
98 }
99 }
100
101 // Group by provider — one listing request per provider per run.
102 $by_provider = array();
103 foreach ($configured as $model) {
104 $provider = self::provider_for_model($model);
105 if ($provider === '') {
106 continue; // no listing endpoint for this provider — skip
107 }
108 $by_provider[$provider][] = $model;
109 }
110
111 foreach ($by_provider as $provider => $models) {
112 $key_option = MxChat_Model_Catalog::key_option_for_provider($provider);
113 $api_key = ($key_option !== '' && isset($options[$key_option]))
114 ? trim((string) $options[$key_option])
115 : '';
116 if ($api_key === '') {
117 continue; // no key stored → zero HTTP calls for this provider
118 }
119
120 $request = MxChat_Model_Catalog::models_listing_request($provider, $api_key);
121 if ($request === null) {
122 continue;
123 }
124
125 $response = wp_remote_get($request['url'], array(
126 'timeout' => 15,
127 'headers' => $request['headers'],
128 ));
129 if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) {
130 continue; // fail open — keep last known state
131 }
132
133 $parsed = self::extract_ids($provider, wp_remote_retrieve_body($response));
134 if ($parsed === null) {
135 continue; // unrecognized shape / empty listing — fail open
136 }
137
138 foreach ($models as $model) {
139 if (in_array($model, $parsed['ids'], true)) {
140 unset($missing[$model]); // listed → healthy, no probe needed
141 continue;
142 }
143 // Absent from a TRUNCATED listing is inconclusive — the model
144 // could be on a page we didn't fetch. Keep the previous state.
145 if (!empty($parsed['truncated'])) {
146 continue;
147 }
148 // STAGE 2. Absent from the listing is only a SUSPICION (see
149 // inference_probe_request() for the live evidence). Confirm with
150 // one 1-token call before saying anything to the user.
151 $verdict = self::confirm_retired($provider, $api_key, $model);
152 if ($verdict === true) {
153 if (!isset($missing[$model]) || !is_array($missing[$model])) {
154 $missing[$model] = array(
155 'provider' => $provider,
156 'first_missing_at' => time(),
157 );
158 }
159 $missing[$model]['checked_at'] = time();
160 } elseif ($verdict === false) {
161 unset($missing[$model]); // answered fine — unlisted alias
162 }
163 // null = inconclusive (transport error, rate limit, auth
164 // problem): keep whatever state we already had.
165 }
166 }
167
168 update_option(self::OPTION, array(
169 'checked_at' => time(),
170 'missing' => $missing,
171 ), false);
172 }
173
174 /**
175 * Does this model still exist? One 1-token call; the answer text is
176 * discarded. Deliberately asymmetric — the ONLY outcome that may raise a
177 * user-facing warning is an explicit "this model does not exist".
178 *
179 * @return bool|null true = provider says the model is gone (flag it)
180 * false = the model answered (definitely alive)
181 * null = inconclusive; change nothing (fail open)
182 */
183 private static function confirm_retired($provider, $api_key, $model) {
184 $probe = MxChat_Model_Catalog::inference_probe_request($provider, $api_key, $model);
185 if ($probe === null) {
186 return null;
187 }
188
189 $response = wp_remote_post($probe['url'], array(
190 'timeout' => 20,
191 'headers' => $probe['headers'],
192 'body' => wp_json_encode($probe['body']),
193 ));
194 if (is_wp_error($response)) {
195 return null;
196 }
197
198 $code = (int) wp_remote_retrieve_response_code($response);
199 if ($code === 200) {
200 return false; // answered — alive, whatever the listing said
201 }
202 // A 404 is necessary but not sufficient: a wrong URL would also 404.
203 // Require the provider's own not-found/deprecated vocabulary too.
204 if ($code !== 404) {
205 return null; // 401/403/429/5xx → tells us nothing about the model
206 }
207
208 $body = strtolower((string) wp_remote_retrieve_body($response));
209 $markers = array(
210 'model_not_found',
211 'not_found_error',
212 'model_deprecated',
213 'does not exist',
214 'is not found',
215 'unknown model',
216 );
217 foreach ($markers as $marker) {
218 if (strpos($body, $marker) !== false) {
219 return true;
220 }
221 }
222
223 return null; // 404 we don't recognize — stay quiet
224 }
225
226 /**
227 * Every model actually in use: the chat model, the content model, plus
228 * whatever add-ons route chats through (plan 202df5 — mxchat-multi-bot's
229 * per-bot overrides were invisible here, so a dead per-bot model produced
230 * no signal anywhere until a customer reported the bot silent).
231 *
232 * The `mxchat_models_in_use` filter receives and must return a flat array
233 * of model-id strings. Contributions are sanitized here (strings only,
234 * trimmed, de-duplicated); a filter that returns garbage is ignored rather
235 * than allowed to break the check.
236 */
237 private static function configured_models($options) {
238 $configured = array();
239 foreach (array('model', 'content_model') as $key) {
240 $model = isset($options[$key]) ? trim((string) $options[$key]) : '';
241 if ($model !== '') {
242 $configured[$model] = true;
243 }
244 }
245 $models = array_keys($configured);
246
247 $filtered = apply_filters('mxchat_models_in_use', $models);
248 if (!is_array($filtered)) {
249 return $models; // fail open on a broken filter return
250 }
251 $clean = array();
252 foreach ($filtered as $model) {
253 if (is_string($model) && trim($model) !== '') {
254 $clean[trim($model)] = true;
255 }
256 }
257 return array_keys($clean);
258 }
259
260 /**
261 * Which checkable provider owns this model id. Catalog first; ids the
262 * catalog no longer lists (already-retired entries still saved on old
263 * installs — exactly the ones this check exists for) fall back to the
264 * provider family prefix. '' = not checkable (openrouter / deepseek /
265 * custom / unknown) — skip.
266 */
267 private static function provider_for_model($model) {
268 $provider = MxChat_Model_Catalog::provider_for_chat_model($model);
269
270 if ($provider === '') {
271 $prefix_map = array(
272 'gpt-' => 'openai',
273 'o1-' => 'openai',
274 'o3-' => 'openai',
275 'o4-' => 'openai',
276 'claude-' => 'claude',
277 'gemini-' => 'gemini',
278 'grok-' => 'xai',
279 );
280 foreach ($prefix_map as $prefix => $slug) {
281 if (strpos($model, $prefix) === 0) {
282 $provider = $slug;
283 break;
284 }
285 }
286 }
287
288 return in_array($provider, self::$checkable_providers, true) ? $provider : '';
289 }
290
291 /**
292 * Minimal per-provider parse: the flat list of listed model ids, plus
293 * whether the listing was truncated (more pages exist). No schema
294 * ambitions beyond "does this id appear anywhere".
295 *
296 * @return array|null array('ids' => string[], 'truncated' => bool),
297 * or null when the body isn't a recognizable listing.
298 */
299 private static function extract_ids($provider, $body) {
300 $json = json_decode((string) $body, true);
301 if (!is_array($json)) {
302 return null;
303 }
304
305 $ids = array();
306
307 if ('gemini' === $provider) {
308 if (!isset($json['models']) || !is_array($json['models'])) {
309 return null;
310 }
311 foreach ($json['models'] as $entry) {
312 if (!empty($entry['name']) && is_string($entry['name'])) {
313 // models.list names entries "models/<id>".
314 $ids[] = preg_replace('#^models/#', '', $entry['name']);
315 }
316 }
317 $truncated = !empty($json['nextPageToken']);
318 } else {
319 // OpenAI / xAI / Anthropic all wrap the list in data[].id.
320 if (!isset($json['data']) || !is_array($json['data'])) {
321 return null;
322 }
323 foreach ($json['data'] as $entry) {
324 if (!empty($entry['id']) && is_string($entry['id'])) {
325 $ids[] = $entry['id'];
326 }
327 }
328 // Anthropic paginates with has_more; OpenAI / xAI never set it.
329 $truncated = !empty($json['has_more']);
330 }
331
332 if (empty($ids)) {
333 return null; // an "empty" provider listing is noise, not signal
334 }
335
336 return array('ids' => $ids, 'truncated' => $truncated);
337 }
338
339 /* ---------------------------------------------------------------------
340 * The notice
341 * ------------------------------------------------------------------ */
342
343 /**
344 * Public accessor for add-ons that render their own warning surfaces
345 * (plan 202df5 — mxchat-multi-bot badges flagged models on its bot list,
346 * because MxChat's branded admin shell buries core admin_notices there).
347 * Returns the same map the core notices render from: model id => info
348 * (provider, first_missing_at, checked_at), restricted to models still in
349 * use. Empty array = nothing is flagged.
350 */
351 public static function flagged() {
352 return self::flagged_models();
353 }
354
355 /**
356 * The models currently flagged AND still configured. Empty array = nothing
357 * to say. Shared by both rendering surfaces.
358 */
359 private static function flagged_models() {
360 $state = get_option(self::OPTION);
361 if (!is_array($state) || empty($state['missing']) || !is_array($state['missing'])) {
362 return array();
363 }
364
365 // Self-clearing: only surface models that are STILL configured now —
366 // switching models hides the notice immediately, the next cron run
367 // prunes the stored flag.
368 $options = get_option('mxchat_options', array());
369 if (!is_array($options)) {
370 $options = array();
371 }
372 $configured = self::configured_models($options);
373
374 $flagged = array();
375 foreach ($state['missing'] as $model => $info) {
376 if (in_array((string) $model, $configured, true)) {
377 $flagged[(string) $model] = is_array($info) ? $info : array();
378 }
379 }
380 return $flagged;
381 }
382
383 /**
384 * Deep link to the Settings screen with the model picker already open.
385 *
386 * TRAP: link to mxchat-SETTINGS, not the mxchat-max parent slug. Hitting
387 * ?page=mxchat-max wp_safe_redirect()s to ?page=mxchat-settings once
388 * onboarding is dismissed, and the redirect DROPS every extra query arg —
389 * so a picker deep link hung off mxchat-max silently arrives with no param
390 * and the modal never opens (caught by the b65e8d browser rig).
391 */
392 private static function picker_url() {
393 return admin_url('admin.php?page=mxchat-settings&mxchat_open_model_picker=1');
394 }
395
396 /** One sentence per flagged model, already escaped for output. */
397 private static function sentence($model, $info) {
398 $provider_label = MxChat_Model_Catalog::provider_label(isset($info['provider']) ? $info['provider'] : '');
399 return sprintf(
400 /* translators: 1: model id, 2: provider name */
401 esc_html__('Your configured model %1$s no longer appears in the %2$s model list — it may be retired or retiring. Pick a current model before the chatbot stops responding.', 'mxchat'),
402 '<code>' . esc_html($model) . '</code>',
403 esc_html($provider_label !== '' ? $provider_label : __('provider', 'mxchat'))
404 );
405 }
406
407 /**
408 * Standard WP admin notice — for admin screens OUTSIDE MxChat's own pages.
409 *
410 * WHY THE SCOPING IS INVERTED from the obvious "only on our pages": every
411 * MxChat admin screen renders the branded .mxch-admin-wrapper shell, which
412 * paints over the whole #wpbody-content notice region. A core-style notice
413 * there is present in the DOM, passes a marker grep, and is INVISIBLE to
414 * the user — WP's own update-nag is buried the same way (proven by
415 * screenshot during this build; the same failure class as the invisible
416 * settings toggle). So MxChat pages get the in-shell renderer below, and
417 * the core notice is left to do its job on Dashboard / Plugins / Updates,
418 * where it renders normally and where an owner who never opens MxChat will
419 * still see that their chatbot is about to stop answering.
420 */
421 public static function render_notice() {
422 if (!current_user_can('manage_options')) {
423 return;
424 }
425
426 $page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
427 if (strpos($page, 'mxchat') === 0) {
428 return; // branded shell covers it — handled by render_inline_notice()
429 }
430
431 $flagged = self::flagged_models();
432 if (empty($flagged)) {
433 return;
434 }
435 ?>
436 <div class="notice notice-warning mxchat-model-liveness-notice">
437 <p><strong><?php esc_html_e('MxChat: a configured AI model may be retiring', 'mxchat'); ?></strong></p>
438 <?php foreach ($flagged as $model => $info) : ?>
439 <p><?php echo wp_kses(self::sentence($model, $info), array('code' => array())); ?></p>
440 <?php endforeach; ?>
441 <p><a href="<?php echo esc_url(self::picker_url()); ?>"><?php esc_html_e('Choose a current model in MxChat Settings', 'mxchat'); ?></a></p>
442 </div>
443 <?php
444 }
445
446 /**
447 * In-shell notice for MxChat's branded admin pages, in the design system's
448 * own .mxch-notice component. Called from the AI Models section of
449 * includes/admin-settings-page.php — directly above the Chat Model field,
450 * which is both where the warning is relevant and where it gets fixed.
451 */
452 public static function render_inline_notice() {
453 if (!current_user_can('manage_options')) {
454 return;
455 }
456
457 $flagged = self::flagged_models();
458 if (empty($flagged)) {
459 return;
460 }
461 ?>
462 <div class="mxch-notice mxch-notice-warning mxch-notice-block mxchat-model-liveness-notice-inline">
463 <svg class="mxch-notice-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
464 <div>
465 <strong><?php esc_html_e('A configured AI model may be retiring', 'mxchat'); ?></strong>
466 <?php foreach ($flagged as $model => $info) : ?>
467 <p><?php echo wp_kses(self::sentence($model, $info), array('code' => array())); ?></p>
468 <?php endforeach; ?>
469 </div>
470 </div>
471 <?php
472 }
473 }
474