PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 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 All 156 releases
← All changes | includes/class-mxchat-model-liveness.php +474 -473 3.2.22 → 3.2.25 View file →
@@ -1,473 +1,474 @@
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 -}
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 + 'no longer available', // Gemini: "This model ... is no longer available to new users" (plan 1bcd1c)
217 + );
218 + foreach ($markers as $marker) {
219 + if (strpos($body, $marker) !== false) {
220 + return true;
221 + }
222 + }
223 +
224 + return null; // 404 we don't recognize — stay quiet
225 + }
226 +
227 + /**
228 + * Every model actually in use: the chat model, the content model, plus
229 + * whatever add-ons route chats through (plan 202df5 — mxchat-multi-bot's
230 + * per-bot overrides were invisible here, so a dead per-bot model produced
231 + * no signal anywhere until a customer reported the bot silent).
232 + *
233 + * The `mxchat_models_in_use` filter receives and must return a flat array
234 + * of model-id strings. Contributions are sanitized here (strings only,
235 + * trimmed, de-duplicated); a filter that returns garbage is ignored rather
236 + * than allowed to break the check.
237 + */
238 + private static function configured_models($options) {
239 + $configured = array();
240 + foreach (array('model', 'content_model') as $key) {
241 + $model = isset($options[$key]) ? trim((string) $options[$key]) : '';
242 + if ($model !== '') {
243 + $configured[$model] = true;
244 + }
245 + }
246 + $models = array_keys($configured);
247 +
248 + $filtered = apply_filters('mxchat_models_in_use', $models);
249 + if (!is_array($filtered)) {
250 + return $models; // fail open on a broken filter return
251 + }
252 + $clean = array();
253 + foreach ($filtered as $model) {
254 + if (is_string($model) && trim($model) !== '') {
255 + $clean[trim($model)] = true;
256 + }
257 + }
258 + return array_keys($clean);
259 + }
260 +
261 + /**
262 + * Which checkable provider owns this model id. Catalog first; ids the
263 + * catalog no longer lists (already-retired entries still saved on old
264 + * installs — exactly the ones this check exists for) fall back to the
265 + * provider family prefix. '' = not checkable (openrouter / deepseek /
266 + * custom / unknown) — skip.
267 + */
268 + private static function provider_for_model($model) {
269 + $provider = MxChat_Model_Catalog::provider_for_chat_model($model);
270 +
271 + if ($provider === '') {
272 + $prefix_map = array(
273 + 'gpt-' => 'openai',
274 + 'o1-' => 'openai',
275 + 'o3-' => 'openai',
276 + 'o4-' => 'openai',
277 + 'claude-' => 'claude',
278 + 'gemini-' => 'gemini',
279 + 'grok-' => 'xai',
280 + );
281 + foreach ($prefix_map as $prefix => $slug) {
282 + if (strpos($model, $prefix) === 0) {
283 + $provider = $slug;
284 + break;
285 + }
286 + }
287 + }
288 +
289 + return in_array($provider, self::$checkable_providers, true) ? $provider : '';
290 + }
291 +
292 + /**
293 + * Minimal per-provider parse: the flat list of listed model ids, plus
294 + * whether the listing was truncated (more pages exist). No schema
295 + * ambitions beyond "does this id appear anywhere".
296 + *
297 + * @return array|null array('ids' => string[], 'truncated' => bool),
298 + * or null when the body isn't a recognizable listing.
299 + */
300 + private static function extract_ids($provider, $body) {
301 + $json = json_decode((string) $body, true);
302 + if (!is_array($json)) {
303 + return null;
304 + }
305 +
306 + $ids = array();
307 +
308 + if ('gemini' === $provider) {
309 + if (!isset($json['models']) || !is_array($json['models'])) {
310 + return null;
311 + }
312 + foreach ($json['models'] as $entry) {
313 + if (!empty($entry['name']) && is_string($entry['name'])) {
314 + // models.list names entries "models/<id>".
315 + $ids[] = preg_replace('#^models/#', '', $entry['name']);
316 + }
317 + }
318 + $truncated = !empty($json['nextPageToken']);
319 + } else {
320 + // OpenAI / xAI / Anthropic all wrap the list in data[].id.
321 + if (!isset($json['data']) || !is_array($json['data'])) {
322 + return null;
323 + }
324 + foreach ($json['data'] as $entry) {
325 + if (!empty($entry['id']) && is_string($entry['id'])) {
326 + $ids[] = $entry['id'];
327 + }
328 + }
329 + // Anthropic paginates with has_more; OpenAI / xAI never set it.
330 + $truncated = !empty($json['has_more']);
331 + }
332 +
333 + if (empty($ids)) {
334 + return null; // an "empty" provider listing is noise, not signal
335 + }
336 +
337 + return array('ids' => $ids, 'truncated' => $truncated);
338 + }
339 +
340 + /* ---------------------------------------------------------------------
341 + * The notice
342 + * ------------------------------------------------------------------ */
343 +
344 + /**
345 + * Public accessor for add-ons that render their own warning surfaces
346 + * (plan 202df5 — mxchat-multi-bot badges flagged models on its bot list,
347 + * because MxChat's branded admin shell buries core admin_notices there).
348 + * Returns the same map the core notices render from: model id => info
349 + * (provider, first_missing_at, checked_at), restricted to models still in
350 + * use. Empty array = nothing is flagged.
351 + */
352 + public static function flagged() {
353 + return self::flagged_models();
354 + }
355 +
356 + /**
357 + * The models currently flagged AND still configured. Empty array = nothing
358 + * to say. Shared by both rendering surfaces.
359 + */
360 + private static function flagged_models() {
361 + $state = get_option(self::OPTION);
362 + if (!is_array($state) || empty($state['missing']) || !is_array($state['missing'])) {
363 + return array();
364 + }
365 +
366 + // Self-clearing: only surface models that are STILL configured now —
367 + // switching models hides the notice immediately, the next cron run
368 + // prunes the stored flag.
369 + $options = get_option('mxchat_options', array());
370 + if (!is_array($options)) {
371 + $options = array();
372 + }
373 + $configured = self::configured_models($options);
374 +
375 + $flagged = array();
376 + foreach ($state['missing'] as $model => $info) {
377 + if (in_array((string) $model, $configured, true)) {
378 + $flagged[(string) $model] = is_array($info) ? $info : array();
379 + }
380 + }
381 + return $flagged;
382 + }
383 +
384 + /**
385 + * Deep link to the Settings screen with the model picker already open.
386 + *
387 + * TRAP: link to mxchat-SETTINGS, not the mxchat-max parent slug. Hitting
388 + * ?page=mxchat-max wp_safe_redirect()s to ?page=mxchat-settings once
389 + * onboarding is dismissed, and the redirect DROPS every extra query arg —
390 + * so a picker deep link hung off mxchat-max silently arrives with no param
391 + * and the modal never opens (caught by the b65e8d browser rig).
392 + */
393 + private static function picker_url() {
394 + return admin_url('admin.php?page=mxchat-settings&mxchat_open_model_picker=1');
395 + }
396 +
397 + /** One sentence per flagged model, already escaped for output. */
398 + private static function sentence($model, $info) {
399 + $provider_label = MxChat_Model_Catalog::provider_label(isset($info['provider']) ? $info['provider'] : '');
400 + return sprintf(
401 + /* translators: 1: model id, 2: provider name */
402 + 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'),
403 + '<code>' . esc_html($model) . '</code>',
404 + esc_html($provider_label !== '' ? $provider_label : __('provider', 'mxchat'))
405 + );
406 + }
407 +
408 + /**
409 + * Standard WP admin notice — for admin screens OUTSIDE MxChat's own pages.
410 + *
411 + * WHY THE SCOPING IS INVERTED from the obvious "only on our pages": every
412 + * MxChat admin screen renders the branded .mxch-admin-wrapper shell, which
413 + * paints over the whole #wpbody-content notice region. A core-style notice
414 + * there is present in the DOM, passes a marker grep, and is INVISIBLE to
415 + * the user — WP's own update-nag is buried the same way (proven by
416 + * screenshot during this build; the same failure class as the invisible
417 + * settings toggle). So MxChat pages get the in-shell renderer below, and
418 + * the core notice is left to do its job on Dashboard / Plugins / Updates,
419 + * where it renders normally and where an owner who never opens MxChat will
420 + * still see that their chatbot is about to stop answering.
421 + */
422 + public static function render_notice() {
423 + if (!current_user_can('manage_options')) {
424 + return;
425 + }
426 +
427 + $page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
428 + if (strpos($page, 'mxchat') === 0) {
429 + return; // branded shell covers it — handled by render_inline_notice()
430 + }
431 +
432 + $flagged = self::flagged_models();
433 + if (empty($flagged)) {
434 + return;
435 + }
436 + ?>
437 + <div class="notice notice-warning mxchat-model-liveness-notice">
438 + <p><strong><?php esc_html_e('MxChat: a configured AI model may be retiring', 'mxchat'); ?></strong></p>
439 + <?php foreach ($flagged as $model => $info) : ?>
440 + <p><?php echo wp_kses(self::sentence($model, $info), array('code' => array())); ?></p>
441 + <?php endforeach; ?>
442 + <p><a href="<?php echo esc_url(self::picker_url()); ?>"><?php esc_html_e('Choose a current model in MxChat Settings', 'mxchat'); ?></a></p>
443 + </div>
444 + <?php
445 + }
446 +
447 + /**
448 + * In-shell notice for MxChat's branded admin pages, in the design system's
449 + * own .mxch-notice component. Called from the AI Models section of
450 + * includes/admin-settings-page.php — directly above the Chat Model field,
451 + * which is both where the warning is relevant and where it gets fixed.
452 + */
453 + public static function render_inline_notice() {
454 + if (!current_user_can('manage_options')) {
455 + return;
456 + }
457 +
458 + $flagged = self::flagged_models();
459 + if (empty($flagged)) {
460 + return;
461 + }
462 + ?>
463 + <div class="mxch-notice mxch-notice-warning mxch-notice-block mxchat-model-liveness-notice-inline">
464 + <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>
465 + <div>
466 + <strong><?php esc_html_e('A configured AI model may be retiring', 'mxchat'); ?></strong>
467 + <?php foreach ($flagged as $model => $info) : ?>
468 + <p><?php echo wp_kses(self::sentence($model, $info), array('code' => array())); ?></p>
469 + <?php endforeach; ?>
470 + </div>
471 + </div>
472 + <?php
473 + }
474 +}