$models) { $key_option = MxChat_Model_Catalog::key_option_for_provider($provider); $api_key = ($key_option !== '' && isset($options[$key_option])) ? trim((string) $options[$key_option]) : ''; if ($api_key === '') { continue; // no key stored → zero HTTP calls for this provider } $request = MxChat_Model_Catalog::models_listing_request($provider, $api_key); if ($request === null) { continue; } $response = wp_remote_get($request['url'], array( 'timeout' => 15, 'headers' => $request['headers'], )); if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) { continue; // fail open — keep last known state } $parsed = self::extract_ids($provider, wp_remote_retrieve_body($response)); if ($parsed === null) { continue; // unrecognized shape / empty listing — fail open } foreach ($models as $model) { if (in_array($model, $parsed['ids'], true)) { unset($missing[$model]); // listed → healthy, no probe needed continue; } // Absent from a TRUNCATED listing is inconclusive — the model // could be on a page we didn't fetch. Keep the previous state. if (!empty($parsed['truncated'])) { continue; } // STAGE 2. Absent from the listing is only a SUSPICION (see // inference_probe_request() for the live evidence). Confirm with // one 1-token call before saying anything to the user. $verdict = self::confirm_retired($provider, $api_key, $model); if ($verdict === true) { if (!isset($missing[$model]) || !is_array($missing[$model])) { $missing[$model] = array( 'provider' => $provider, 'first_missing_at' => time(), ); } $missing[$model]['checked_at'] = time(); } elseif ($verdict === false) { unset($missing[$model]); // answered fine — unlisted alias } // null = inconclusive (transport error, rate limit, auth // problem): keep whatever state we already had. } } update_option(self::OPTION, array( 'checked_at' => time(), 'missing' => $missing, ), false); } /** * Does this model still exist? One 1-token call; the answer text is * discarded. Deliberately asymmetric — the ONLY outcome that may raise a * user-facing warning is an explicit "this model does not exist". * * @return bool|null true = provider says the model is gone (flag it) * false = the model answered (definitely alive) * null = inconclusive; change nothing (fail open) */ private static function confirm_retired($provider, $api_key, $model) { $probe = MxChat_Model_Catalog::inference_probe_request($provider, $api_key, $model); if ($probe === null) { return null; } $response = wp_remote_post($probe['url'], array( 'timeout' => 20, 'headers' => $probe['headers'], 'body' => wp_json_encode($probe['body']), )); if (is_wp_error($response)) { return null; } $code = (int) wp_remote_retrieve_response_code($response); if ($code === 200) { return false; // answered — alive, whatever the listing said } // A 404 is necessary but not sufficient: a wrong URL would also 404. // Require the provider's own not-found/deprecated vocabulary too. if ($code !== 404) { return null; // 401/403/429/5xx → tells us nothing about the model } $body = strtolower((string) wp_remote_retrieve_body($response)); $markers = array( 'model_not_found', 'not_found_error', 'model_deprecated', 'does not exist', 'is not found', 'unknown model', 'no longer available', // Gemini: "This model ... is no longer available to new users" (plan 1bcd1c) ); foreach ($markers as $marker) { if (strpos($body, $marker) !== false) { return true; } } return null; // 404 we don't recognize — stay quiet } /** * Every model actually in use: the chat model, the content model, plus * whatever add-ons route chats through (plan 202df5 — mxchat-multi-bot's * per-bot overrides were invisible here, so a dead per-bot model produced * no signal anywhere until a customer reported the bot silent). * * The `mxchat_models_in_use` filter receives and must return a flat array * of model-id strings. Contributions are sanitized here (strings only, * trimmed, de-duplicated); a filter that returns garbage is ignored rather * than allowed to break the check. */ private static function configured_models($options) { $configured = array(); foreach (array('model', 'content_model') as $key) { $model = isset($options[$key]) ? trim((string) $options[$key]) : ''; if ($model !== '') { $configured[$model] = true; } } $models = array_keys($configured); $filtered = apply_filters('mxchat_models_in_use', $models); if (!is_array($filtered)) { return $models; // fail open on a broken filter return } $clean = array(); foreach ($filtered as $model) { if (is_string($model) && trim($model) !== '') { $clean[trim($model)] = true; } } return array_keys($clean); } /** * Which checkable provider owns this model id. Catalog first; ids the * catalog no longer lists (already-retired entries still saved on old * installs — exactly the ones this check exists for) fall back to the * provider family prefix. '' = not checkable (openrouter / deepseek / * custom / unknown) — skip. */ private static function provider_for_model($model) { $provider = MxChat_Model_Catalog::provider_for_chat_model($model); if ($provider === '') { $prefix_map = array( 'gpt-' => 'openai', 'o1-' => 'openai', 'o3-' => 'openai', 'o4-' => 'openai', 'claude-' => 'claude', 'gemini-' => 'gemini', 'grok-' => 'xai', ); foreach ($prefix_map as $prefix => $slug) { if (strpos($model, $prefix) === 0) { $provider = $slug; break; } } } return in_array($provider, self::$checkable_providers, true) ? $provider : ''; } /** * Minimal per-provider parse: the flat list of listed model ids, plus * whether the listing was truncated (more pages exist). No schema * ambitions beyond "does this id appear anywhere". * * @return array|null array('ids' => string[], 'truncated' => bool), * or null when the body isn't a recognizable listing. */ private static function extract_ids($provider, $body) { $json = json_decode((string) $body, true); if (!is_array($json)) { return null; } $ids = array(); if ('gemini' === $provider) { if (!isset($json['models']) || !is_array($json['models'])) { return null; } foreach ($json['models'] as $entry) { if (!empty($entry['name']) && is_string($entry['name'])) { // models.list names entries "models/". $ids[] = preg_replace('#^models/#', '', $entry['name']); } } $truncated = !empty($json['nextPageToken']); } else { // OpenAI / xAI / Anthropic all wrap the list in data[].id. if (!isset($json['data']) || !is_array($json['data'])) { return null; } foreach ($json['data'] as $entry) { if (!empty($entry['id']) && is_string($entry['id'])) { $ids[] = $entry['id']; } } // Anthropic paginates with has_more; OpenAI / xAI never set it. $truncated = !empty($json['has_more']); } if (empty($ids)) { return null; // an "empty" provider listing is noise, not signal } return array('ids' => $ids, 'truncated' => $truncated); } /* --------------------------------------------------------------------- * The notice * ------------------------------------------------------------------ */ /** * Public accessor for add-ons that render their own warning surfaces * (plan 202df5 — mxchat-multi-bot badges flagged models on its bot list, * because MxChat's branded admin shell buries core admin_notices there). * Returns the same map the core notices render from: model id => info * (provider, first_missing_at, checked_at), restricted to models still in * use. Empty array = nothing is flagged. */ public static function flagged() { return self::flagged_models(); } /** * The models currently flagged AND still configured. Empty array = nothing * to say. Shared by both rendering surfaces. */ private static function flagged_models() { $state = get_option(self::OPTION); if (!is_array($state) || empty($state['missing']) || !is_array($state['missing'])) { return array(); } // Self-clearing: only surface models that are STILL configured now — // switching models hides the notice immediately, the next cron run // prunes the stored flag. $options = get_option('mxchat_options', array()); if (!is_array($options)) { $options = array(); } $configured = self::configured_models($options); $flagged = array(); foreach ($state['missing'] as $model => $info) { if (in_array((string) $model, $configured, true)) { $flagged[(string) $model] = is_array($info) ? $info : array(); } } return $flagged; } /** * Deep link to the Settings screen with the model picker already open. * * TRAP: link to mxchat-SETTINGS, not the mxchat-max parent slug. Hitting * ?page=mxchat-max wp_safe_redirect()s to ?page=mxchat-settings once * onboarding is dismissed, and the redirect DROPS every extra query arg — * so a picker deep link hung off mxchat-max silently arrives with no param * and the modal never opens (caught by the b65e8d browser rig). */ private static function picker_url() { return admin_url('admin.php?page=mxchat-settings&mxchat_open_model_picker=1'); } /** One sentence per flagged model, already escaped for output. */ private static function sentence($model, $info) { $provider_label = MxChat_Model_Catalog::provider_label(isset($info['provider']) ? $info['provider'] : ''); return sprintf( /* translators: 1: model id, 2: provider name */ 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'), '' . esc_html($model) . '', esc_html($provider_label !== '' ? $provider_label : __('provider', 'mxchat')) ); } /** * Standard WP admin notice — for admin screens OUTSIDE MxChat's own pages. * * WHY THE SCOPING IS INVERTED from the obvious "only on our pages": every * MxChat admin screen renders the branded .mxch-admin-wrapper shell, which * paints over the whole #wpbody-content notice region. A core-style notice * there is present in the DOM, passes a marker grep, and is INVISIBLE to * the user — WP's own update-nag is buried the same way (proven by * screenshot during this build; the same failure class as the invisible * settings toggle). So MxChat pages get the in-shell renderer below, and * the core notice is left to do its job on Dashboard / Plugins / Updates, * where it renders normally and where an owner who never opens MxChat will * still see that their chatbot is about to stop answering. */ public static function render_notice() { if (!current_user_can('manage_options')) { return; } $page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; if (strpos($page, 'mxchat') === 0) { return; // branded shell covers it — handled by render_inline_notice() } $flagged = self::flagged_models(); if (empty($flagged)) { return; } ?>

$info) : ?>

array())); ?>

$info) : ?>

array())); ?>