PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.22
MxChat – AI Chatbot & Content Generation for WordPress v3.2.22
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
mxchat-basic / includes / class-mxchat-model-catalog.php

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

899 lines 51.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MxChat Model Catalog — single source of truth for chat + embedding models.
4 *
5 * Plan: plan-mxchat-20260527-d14e89. Consolidates model definitions that
6 * previously lived in three places (Settings dropdown in
7 * class-mxchat-admin.php, autosave allowlist in admin/class-ajax-handler.php,
8 * modal picker catalog in js/mxchat-admin.js, plus a fourth in
9 * admin-onboarding-page.php). Adding a new chat/embedding model now means
10 * editing THIS file only.
11 *
12 * Schema:
13 * chat_models() / embedding_models() return:
14 * [ providerSlug => [
15 * 'label' => Display name shown in pickers,
16 * 'key_option' => sub-key under get_option('mxchat_options'),
17 * 'requires_key_to_load_models' => bool — OpenRouter is true (key needed
18 * before model list can be fetched). Surfaces consult
19 * this to disable / gate the provider in pickers that
20 * can't run the inline key-then-fetch flow.
21 * 'models' => [ model_id => [ 'label' => …, 'description' => … ] ]
22 * ]
23 * ]
24 *
25 * @package MxChat
26 * @since 3.2.7
27 */
28
29 if (!defined('ABSPATH')) {
30 exit;
31 }
32
33 class MxChat_Model_Catalog {
34
35 /**
36 * Chat models grouped by provider. Order within a provider matches the
37 * order shown in the Settings modal picker — keep the recommended /
38 * flagship model at top.
39 */
40 public static function chat_models() {
41 return array(
42 'openrouter' => array(
43 'label' => __('OpenRouter', 'mxchat'),
44 'key_option' => 'openrouter_api_key',
45 'requires_key_to_load_models' => true,
46 'disable_in_onboarding' => true,
47 'models' => array(
48 'openrouter' => array(
49 'label' => 'OpenRouter',
50 'description' => __('Access 100+ models from multiple providers (add API key to browse)', 'mxchat'),
51 ),
52 ),
53 ),
54 'gemini' => array(
55 'label' => __('Google Gemini', 'mxchat'),
56 'key_option' => 'gemini_api_key',
57 'requires_key_to_load_models' => false,
58 'models' => array(
59 'gemini-3.8-flash' => array('label' => 'Gemini 3.8 Flash', 'description' => __('Stable — newest Flash generation, recommended default', 'mxchat')),
60 'gemini-3.7-flash' => array('label' => 'Gemini 3.7 Flash', 'description' => __('Stable — efficiency-first Flash', 'mxchat')),
61 'gemini-3.6-flash' => array('label' => 'Gemini 3.6 Flash', 'description' => __('Stable — previous Flash generation', 'mxchat')),
62 'gemini-3.5-flash' => array('label' => 'Gemini 3.5 Flash', 'description' => __('Stable — older Flash, priced above 3.8 Flash on every token', 'mxchat')),
63 'gemini-3.1-pro-preview' => array('label' => 'Gemini 3.1 Pro', 'description' => __('Preview — most intelligent, multimodal & agentic', 'mxchat')),
64 'gemini-3-flash-preview' => array('label' => 'Gemini 3 Flash', 'description' => __('Preview — balanced speed and scale', 'mxchat')),
65 'gemini-3.5-flash-lite' => array('label' => 'Gemini 3.5 Flash-Lite', 'description' => __('Stable — cheapest Gemini, no thinking', 'mxchat')),
66 'gemini-3.1-flash-lite' => array('label' => 'Gemini 3.1 Flash-Lite', 'description' => __('Stable — cost-efficient, high throughput', 'mxchat')),
67 'gemini-3.1-flash-lite-preview' => array('label' => 'Gemini 3.1 Flash-Lite (Preview)', 'description' => __('Preview — latest cost-efficient model', 'mxchat')),
68 'gemini-2.5-pro' => array('label' => 'Gemini 2.5 Pro', 'description' => __('Stable — advanced thinking, code, math & long context', 'mxchat')),
69 'gemini-2.5-flash' => array('label' => 'Gemini 2.5 Flash', 'description' => __('Stable — strong price-performance with thinking', 'mxchat')),
70 'gemini-2.5-flash-lite' => array('label' => 'Gemini 2.5 Flash-Lite', 'description' => __('Stable — ultra fast and low cost', 'mxchat')),
71 ),
72 ),
73 'openai' => array(
74 'label' => __('OpenAI', 'mxchat'),
75 'key_option' => 'api_key',
76 'requires_key_to_load_models' => false,
77 'models' => array(
78 // OpenAI retired gpt-5.1-chat-latest and gpt-5.3-chat-latest on
79 // 2026-08-10 (replacement gpt-5.6-sol per the deprecations page);
80 // stored settings migrate via mxchat_migrate_deprecated_models()
81 // and the integrator carries a read-time rescue (plan e46b8f).
82 //
83 // 'deprecated_on' (Y-m-d) + 'replacement' are optional per-model
84 // fields: pickers label a future date as "retires <date>" and hide
85 // the entry once the date passes (a saved value keeps rendering via
86 // settings_dropdown_groups($current_model)).
87 'gpt-5.6-sol' => array('label' => 'GPT-5.6 Sol', 'description' => __('Recommended — newest OpenAI flagship for reasoning, coding and chat', 'mxchat')),
88 'gpt-5.6-terra' => array('label' => 'GPT-5.6 Terra', 'description' => __('Balanced GPT-5.6 — strong capability at lower cost', 'mxchat')),
89 'gpt-5.6-luna' => array('label' => 'GPT-5.6 Luna', 'description' => __('Fastest and cheapest GPT-5.6 for lightweight tasks', 'mxchat')),
90 'gpt-5.5' => array('label' => 'GPT-5.5', 'description' => __('Previous flagship — powerful reasoning and coding model', 'mxchat')),
91 'gpt-5.4' => array('label' => 'GPT-5.4', 'description' => __('Flagship reasoning and coding model', 'mxchat')),
92 'gpt-5.4-mini' => array('label' => 'GPT-5.4 Mini', 'description' => __('Fast and affordable with 400K context', 'mxchat')),
93 'gpt-5.4-nano' => array('label' => 'GPT-5.4 Nano', 'description' => __('Fastest and cheapest for lightweight tasks', 'mxchat')),
94 'gpt-5.2' => array('label' => 'GPT-5.2', 'description' => __('Best general-purpose & agentic model with fast responses', 'mxchat')),
95 'gpt-5.1-2025-11-13' => array('label' => 'GPT-5.1', 'description' => __('Flagship for coding & agentic tasks with low reasoning (400K context)', 'mxchat')),
96 'gpt-5' => array('label' => 'GPT-5', 'description' => __('Flagship for coding, reasoning, and agentic tasks across domains', 'mxchat'), 'deprecated_on' => '2026-12-11', 'replacement' => 'gpt-5.6-sol'),
97 'gpt-5-mini' => array('label' => 'GPT-5 Mini', 'description' => __('Fast and lightweight', 'mxchat'), 'deprecated_on' => '2026-12-11', 'replacement' => 'gpt-5.6-terra'),
98 'gpt-5-nano' => array('label' => 'GPT-5 Nano', 'description' => __('Fastest and cheapest; ideal for summarization and classification', 'mxchat'), 'deprecated_on' => '2026-12-11', 'replacement' => 'gpt-5.6-luna'),
99 ),
100 ),
101 'claude' => array(
102 'label' => __('Anthropic Claude', 'mxchat'),
103 'key_option' => 'claude_api_key',
104 'requires_key_to_load_models' => false,
105 'models' => array(
106 'claude-fable-5' => array('label' => 'Claude Fable 5', 'description' => __('Latest Flagship — newest and most capable Anthropic model', 'mxchat')),
107 'claude-opus-5' => array('label' => 'Claude Opus 5', 'description' => __('Latest Opus — best for complex agentic and coding work', 'mxchat')),
108 'claude-opus-4-8' => array('label' => 'Claude Opus 4.8', 'description' => __('Previous Opus generation', 'mxchat')),
109 'claude-opus-4-7' => array('label' => 'Claude Opus 4.7', 'description' => __('Previous Anthropic flagship model', 'mxchat')),
110 'claude-opus-4-6' => array('label' => 'Claude Opus 4.6', 'description' => __('Most capable Claude model - recommended', 'mxchat')),
111 'claude-sonnet-5' => array('label' => 'Claude Sonnet 5', 'description' => __('Newest Sonnet - excellent balance of speed and capability', 'mxchat')),
112 'claude-sonnet-4-6' => array('label' => 'Claude Sonnet 4.6', 'description' => __('Previous Sonnet - excellent balance of speed and capability', 'mxchat')),
113 'claude-opus-4-5' => array('label' => 'Claude Opus 4.5', 'description' => __('Highly capable for complex tasks', 'mxchat')),
114 'claude-sonnet-4-5-20250929' => array('label' => 'Claude Sonnet 4.5', 'description' => __('Best for complex agents and coding', 'mxchat')),
115 'claude-haiku-4-5-20251001' => array('label' => 'Claude Haiku 4.5', 'description' => __('Fastest and most intelligent Haiku', 'mxchat')),
116 ),
117 ),
118 'xai' => array(
119 'label' => __('xAI Grok', 'mxchat'),
120 'key_option' => 'xai_api_key',
121 'requires_key_to_load_models' => false,
122 // Refreshed 2026-08-14 (plan-mxchat-20260814-e1aa4a) against TWO
123 // primary sources that agree: a live pull of xAI's /v1/models with
124 // a real key, and docs.x.ai/docs/models. Context windows below are
125 // that listing's own context_length; image-input support for every
126 // current-generation id was confirmed by a 32x32 probe returning a
127 // correct answer (all HTTP 200), not inferred from docs.
128 //
129 // The legacy block below is INTENTIONALLY still here. xAI has
130 // published no retirement dates for these ids and they still answer
131 // HTTP 200 (grok-4-0709 re-probed 2026-08-14), so 'deprecated_on'
132 // would be an invented date and deleting them would strand every
133 // install saved on one. Current generation simply sorts above them.
134 'models' => array(
135 'grok-4.6' => array('label' => 'Grok 4.6', 'description' => __('Newest xAI flagship — 500K context, accepts image input', 'mxchat')),
136 'grok-4.5' => array('label' => 'Grok 4.5', 'description' => __('Previous flagship — 500K context, accepts image input', 'mxchat')),
137 'grok-4.3' => array('label' => 'Grok 4.3', 'description' => __('Largest context in the Grok lineup — 1M tokens, accepts image input', 'mxchat')),
138 'grok-4.20-0309-reasoning' => array('label' => 'Grok 4.20 (Reasoning)', 'description' => __('1M context with reasoning — dated release, pinned behaviour', 'mxchat')),
139 'grok-4.20-0309-non-reasoning'=> array('label' => 'Grok 4.20 (Non-Reasoning)', 'description' => __('1M context without reasoning — faster replies at the same price', 'mxchat')),
140 // --- Previous generations. Still served by xAI, no announced
141 // retirement date, kept so saved selections keep working. ---
142 'grok-4-1-fast-reasoning' => array('label' => 'Grok 4.1 Fast (Reasoning)', 'description' => __('Previous generation — no longer listed by xAI, still served', 'mxchat')),
143 'grok-4-1-fast-non-reasoning' => array('label' => 'Grok 4.1 Fast (Non-Reasoning)', 'description' => __('Previous generation — no longer listed by xAI, still served', 'mxchat')),
144 'grok-4-0709' => array('label' => 'Grok 4', 'description' => __('Previous generation — no longer listed by xAI, still served', 'mxchat')),
145 'grok-3-beta' => array('label' => 'Grok-3', 'description' => __('Older generation — no longer listed by xAI, still served', 'mxchat')),
146 'grok-3-fast-beta' => array('label' => 'Grok-3 Fast', 'description' => __('Older generation — no longer listed by xAI, still served', 'mxchat')),
147 'grok-3-mini-beta' => array('label' => 'Grok-3 Mini', 'description' => __('Older generation — no longer listed by xAI, still served', 'mxchat')),
148 'grok-3-mini-fast-beta' => array('label' => 'Grok-3 Mini Fast', 'description' => __('Older generation — no longer listed by xAI, still served', 'mxchat')),
149 ),
150 ),
151 'deepseek' => array(
152 'label' => __('DeepSeek', 'mxchat'),
153 'key_option' => 'deepseek_api_key',
154 'requires_key_to_load_models' => false,
155 'models' => array(
156 // DeepSeek retired deepseek-chat / deepseek-reasoner on 2026-07-24;
157 // stored settings migrate via mxchat_migrate_deprecated_models().
158 'deepseek-v4-flash' => array('label' => 'DeepSeek V4 Flash', 'description' => __('Fast and cost-effective', 'mxchat')),
159 'deepseek-v4-pro' => array('label' => 'DeepSeek V4 Pro', 'description' => __('Most capable DeepSeek model', 'mxchat')),
160 ),
161 ),
162 'custom' => array(
163 'label' => __('Custom (OpenAI-compatible)', 'mxchat'),
164 'key_option' => 'custom_provider_api_key',
165 'requires_key_to_load_models' => false,
166 'disable_in_onboarding' => true,
167 'models' => array(
168 'custom-provider' => array(
169 'label' => 'Custom Provider',
170 'description' => __('OpenAI-compatible local LLM (Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI) — configure Base URL, key, and model in API Keys tab', 'mxchat'),
171 ),
172 ),
173 ),
174 );
175 }
176
177 /**
178 * Embedding models grouped by provider. Order within a provider matches
179 * the order shown in the Settings dropdown.
180 */
181 public static function embedding_models() {
182 return array(
183 'openai' => array(
184 'label' => __('OpenAI', 'mxchat'),
185 'key_option' => 'api_key',
186 'models' => array(
187 'text-embedding-ada-002' => array('label' => 'Ada 2', 'description' => __('1536 dim, recommended', 'mxchat')),
188 'text-embedding-3-small' => array('label' => 'TE3 Small', 'description' => __('1536 dim, efficient', 'mxchat')),
189 'text-embedding-3-large' => array('label' => 'TE3 Large', 'description' => __('3072 dim, powerful', 'mxchat')),
190 ),
191 ),
192 'voyage' => array(
193 'label' => __('Voyage AI', 'mxchat'),
194 'key_option' => 'voyage_api_key',
195 'models' => array(
196 'voyage-3-large' => array('label' => 'Voyage-3 Large', 'description' => __('2048 dim, most capable', 'mxchat')),
197 ),
198 ),
199 'gemini' => array(
200 'label' => __('Google Gemini', 'mxchat'),
201 'key_option' => 'gemini_api_key',
202 'models' => array(
203 'gemini-embedding-001' => array('label' => 'Gemini Embedding', 'description' => __('1536 dim', 'mxchat')),
204 ),
205 ),
206 );
207 }
208
209 /**
210 * Canonical retired-model map: every chat model id a provider has retired
211 * (or hard-deprecated) that stored settings may still carry, mapped to its
212 * current replacement (plan-mxchat-20260822-202df5).
213 *
214 * THE ONLY PLACE THIS MAPPING LIVES. mxchat_migrate_deprecated_models()
215 * consumes it for the main chat + content models, and add-ons that store
216 * their own model ids (mxchat-multi-bot's per-bot overrides) consume it for
217 * theirs — a second hardcoded list in any consumer is how a stranded-model
218 * bug recurs, so route every new retirement through this map.
219 *
220 * Claude ids map BY TIER so a rescued site lands on the current generation,
221 * not an older intermediate (plan dc91bd). Never map onto a model that is
222 * itself on a deprecation list (the e46b8f rule).
223 *
224 * Entry shape: retired_id => array(
225 * 'to' => replacement model id (must be current, never itself retired),
226 * 'label' => replacement display name for user-facing notices,
227 * 'reason' => notice clause explaining the retirement; deliberately NOT
228 * translated — migration messages are stored in an option and
229 * rendered later, matching the existing migration notices.
230 * )
231 */
232 public static function retired_model_map() {
233 return array(
234 // Anthropic Claude — Opus tier.
235 'claude-3-opus-20240229' => array('to' => 'claude-opus-5', 'label' => 'Claude Opus 5', 'reason' => 'because Anthropic retired the older Claude model'), // Retired Jan 5, 2026
236 'claude-opus-4-20250514' => array('to' => 'claude-opus-5', 'label' => 'Claude Opus 5', 'reason' => 'because Anthropic retired the older Claude model'), // Deprecated
237 'claude-opus-4-1-20250805' => array('to' => 'claude-opus-5', 'label' => 'Claude Opus 5', 'reason' => 'because Anthropic retired the older Claude model'), // Retired Aug 5, 2026 (first-party API)
238 // Anthropic Claude — Sonnet tier.
239 'claude-3-5-sonnet-20240620' => array('to' => 'claude-sonnet-5', 'label' => 'Claude Sonnet 5', 'reason' => 'because Anthropic retired the older Claude model'), // Retired Oct 28, 2025
240 'claude-3-5-sonnet-20241022' => array('to' => 'claude-sonnet-5', 'label' => 'Claude Sonnet 5', 'reason' => 'because Anthropic retired the older Claude model'), // Retired Oct 28, 2025
241 'claude-3-7-sonnet-20250219' => array('to' => 'claude-sonnet-5', 'label' => 'Claude Sonnet 5', 'reason' => 'because Anthropic retired the older Claude model'), // Retired Feb 19, 2026
242 'claude-3-sonnet-20240229' => array('to' => 'claude-sonnet-5', 'label' => 'Claude Sonnet 5', 'reason' => 'because Anthropic retired the older Claude model'), // Legacy
243 'claude-sonnet-4-20250514' => array('to' => 'claude-sonnet-5', 'label' => 'Claude Sonnet 5', 'reason' => 'because Anthropic retired the older Claude model'), // Deprecated
244 // Anthropic Claude — Haiku tier.
245 'claude-3-haiku-20240307' => array('to' => 'claude-haiku-4-5-20251001', 'label' => 'Claude Haiku 4.5', 'reason' => 'because Anthropic retired the older Claude model'), // Legacy
246 'claude-3-5-haiku-20241022' => array('to' => 'claude-haiku-4-5-20251001', 'label' => 'Claude Haiku 4.5', 'reason' => 'because Anthropic retired the older Claude model'), // Deprecated
247 // OpenAI — GPT-4 series + GPT-3.5 Turbo (deprecated 2026-02-17).
248 'gpt-4o' => array('to' => 'gpt-5.6-sol', 'label' => 'GPT-5.6 Sol', 'reason' => 'due to OpenAI deprecating older models'),
249 'gpt-4.1-2025-04-14' => array('to' => 'gpt-5.6-sol', 'label' => 'GPT-5.6 Sol', 'reason' => 'due to OpenAI deprecating older models'),
250 'gpt-4-turbo' => array('to' => 'gpt-5.6-sol', 'label' => 'GPT-5.6 Sol', 'reason' => 'due to OpenAI deprecating older models'),
251 'gpt-4' => array('to' => 'gpt-5.6-sol', 'label' => 'GPT-5.6 Sol', 'reason' => 'due to OpenAI deprecating older models'),
252 'gpt-3.5-turbo' => array('to' => 'gpt-5.6-sol', 'label' => 'GPT-5.6 Sol', 'reason' => 'due to OpenAI deprecating older models'),
253 // OpenAI — the gpt-5.x-chat-latest aliases retired 2026-08-10 (e46b8f).
254 'gpt-5.1-chat-latest' => array('to' => 'gpt-5.6-sol', 'label' => 'GPT-5.6 Sol', 'reason' => 'because OpenAI retires the GPT-5.x Chat Latest models on August 10, 2026'),
255 'gpt-5.3-chat-latest' => array('to' => 'gpt-5.6-sol', 'label' => 'GPT-5.6 Sol', 'reason' => 'because OpenAI retires the GPT-5.x Chat Latest models on August 10, 2026'),
256 // OpenAI — mini tier.
257 'gpt-4o-mini' => array('to' => 'gpt-5-mini', 'label' => 'GPT-5 Mini', 'reason' => 'due to OpenAI deprecating GPT-4 series models'),
258 'gpt-4.1-mini' => array('to' => 'gpt-5-mini', 'label' => 'GPT-5 Mini', 'reason' => 'due to OpenAI deprecating GPT-4 series models'),
259 // DeepSeek — removed at the vendor 2026-07-24 (hard cutoff, every request 400s).
260 'deepseek-chat' => array('to' => 'deepseek-v4-flash', 'label' => 'DeepSeek V4 Flash', 'reason' => 'because DeepSeek retired its older API models on July 24, 2026'),
261 'deepseek-reasoner' => array('to' => 'deepseek-v4-flash', 'label' => 'DeepSeek V4 Flash', 'reason' => 'because DeepSeek retired its older API models on July 24, 2026'),
262 );
263 }
264
265 /**
266 * Returns the display label for the given chat-or-embedding provider slug.
267 * Chat providers checked first; embedding-only providers fall through.
268 */
269 public static function provider_label($slug) {
270 $chat = self::chat_models();
271 if (isset($chat[$slug])) return $chat[$slug]['label'];
272 $emb = self::embedding_models();
273 if (isset($emb[$slug])) return $emb[$slug]['label'];
274 return $slug;
275 }
276
277 /**
278 * Returns the mxchat_options sub-key that holds the API key for the given
279 * provider slug. Chat providers checked first; embedding-only providers
280 * (voyage) fall through.
281 */
282 public static function key_option_for_provider($slug) {
283 $chat = self::chat_models();
284 if (isset($chat[$slug])) return $chat[$slug]['key_option'];
285 $emb = self::embedding_models();
286 if (isset($emb[$slug])) return $emb[$slug]['key_option'];
287 return '';
288 }
289
290 /**
291 * The provider slug that owns a chat model id, or '' when the catalog
292 * doesn't list it (plan b65e8d).
293 *
294 * NOTE for callers doing retirement work: a model the provider has already
295 * retired is frequently ABSENT from this catalog while still saved on an
296 * install — that is precisely the state the liveness check exists to catch.
297 * Treat '' as "catalog doesn't know", not "not a real model", and fall back
298 * to a family-prefix guess if you need one.
299 */
300 public static function provider_for_chat_model($model_id) {
301 $model_id = (string) $model_id;
302 foreach (self::chat_models() as $provider_slug => $provider) {
303 if (isset($provider['models'][$model_id])) {
304 return $provider_slug;
305 }
306 }
307 return '';
308 }
309
310 /**
311 * URL + auth headers for a provider's public model-listing endpoint
312 * (plan b65e8d — the daily liveness check).
313 *
314 * Only providers that actually publish a listing are mapped. OpenRouter is
315 * deliberately excluded: its 'openrouter' catalog entry is a meta-selector
316 * that resolves to a per-account sub-model at request time, so "is the
317 * configured id listed" is not a meaningful question. DeepSeek and the
318 * custom OpenAI-compatible provider are excluded too — a self-hosted or
319 * proxied endpoint's /models is not authoritative about retirement.
320 *
321 * @param string $provider Provider slug.
322 * @param string $api_key The install's key for that provider.
323 * @return array|null array('url' => string, 'headers' => array) or null.
324 */
325 public static function models_listing_request($provider, $api_key) {
326 $api_key = (string) $api_key;
327 if ($api_key === '') {
328 return null;
329 }
330
331 switch ($provider) {
332 case 'openai':
333 return array(
334 'url' => 'https://api.openai.com/v1/models',
335 'headers' => array('Authorization' => 'Bearer ' . $api_key),
336 );
337 case 'claude':
338 return array(
339 'url' => 'https://api.anthropic.com/v1/models?limit=1000',
340 'headers' => array(
341 'x-api-key' => $api_key,
342 'anthropic-version' => '2023-06-01',
343 ),
344 );
345 case 'gemini':
346 return array(
347 'url' => 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000',
348 'headers' => array('x-goog-api-key' => $api_key),
349 );
350 case 'xai':
351 return array(
352 'url' => 'https://api.x.ai/v1/models',
353 'headers' => array('Authorization' => 'Bearer ' . $api_key),
354 );
355 }
356
357 return null;
358 }
359
360 /**
361 * A minimal 1-token chat request used ONLY to confirm whether a model id
362 * still exists (plan b65e8d). Never used for content — the response is
363 * thrown away; only the HTTP status and error code are read.
364 *
365 * WHY THIS EXISTS: a provider's model LISTING is not authoritative about
366 * retirement, in either direction (all probed live 2026-08-13):
367 * - OpenAI KEEPS retired ids listed — gpt-5.3-chat-latest is in
368 * /v1/models and 404s "model_deprecated" on use.
369 * - Anthropic DROPS working aliases — claude-opus-4-5 is absent from
370 * /v1/models and answers HTTP 200 perfectly.
371 * - xAI's listing is partial — grok-4-0709 is absent and answers 200.
372 * So "absent from the listing" is a suspicion, not a verdict. This probe is
373 * the verdict, and it is the only thing allowed to raise a warning.
374 *
375 * @return array|null array('url','headers','body') or null when unsupported.
376 */
377 public static function inference_probe_request($provider, $api_key, $model) {
378 $api_key = (string) $api_key;
379 $model = (string) $model;
380 if ($api_key === '' || $model === '') {
381 return null;
382 }
383
384 $msg = array(array('role' => 'user', 'content' => 'hi'));
385
386 switch ($provider) {
387 case 'openai':
388 return array(
389 'url' => 'https://api.openai.com/v1/chat/completions',
390 'headers' => array('Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json'),
391 'body' => array('model' => $model, 'messages' => $msg, 'max_completion_tokens' => 1),
392 );
393 case 'xai':
394 return array(
395 'url' => 'https://api.x.ai/v1/chat/completions',
396 'headers' => array('Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json'),
397 'body' => array('model' => $model, 'messages' => $msg, 'max_tokens' => 1),
398 );
399 case 'claude':
400 return array(
401 'url' => 'https://api.anthropic.com/v1/messages',
402 'headers' => array(
403 'x-api-key' => $api_key,
404 'anthropic-version' => '2023-06-01',
405 'Content-Type' => 'application/json',
406 ),
407 'body' => array('model' => $model, 'max_tokens' => 1, 'messages' => $msg),
408 );
409 case 'gemini':
410 return array(
411 'url' => 'https://generativelanguage.googleapis.com/v1beta/models/'
412 . rawurlencode($model) . ':generateContent',
413 'headers' => array('x-goog-api-key' => $api_key, 'Content-Type' => 'application/json'),
414 'body' => array(
415 'contents' => array(array('parts' => array(array('text' => 'hi')))),
416 'generationConfig' => array('maxOutputTokens' => 1),
417 ),
418 );
419 }
420
421 return null;
422 }
423
424 /**
425 * Flat allowlist of every chat model id across all providers. Used by the
426 * sanitize() flow in class-mxchat-admin.php and the AJAX autosave validator
427 * in admin/class-ajax-handler.php so a new model id only needs editing in
428 * the catalog above.
429 */
430 public static function chat_model_ids() {
431 $ids = array();
432 foreach (self::chat_models() as $provider) {
433 foreach ($provider['models'] as $id => $_unused) {
434 $ids[] = $id;
435 }
436 }
437 return $ids;
438 }
439
440 /**
441 * Flat allowlist of every embedding model id across all providers.
442 */
443 public static function embedding_model_ids() {
444 $ids = array();
445 foreach (self::embedding_models() as $provider) {
446 foreach ($provider['models'] as $id => $_unused) {
447 $ids[] = $id;
448 }
449 }
450 return $ids;
451 }
452
453 /**
454 * Deprecation-aware picker label for a catalog entry (plan e46b8f).
455 *
456 * @param array $entry Model entry from chat_models().
457 * @return string|null Label to show (with a "retires <date>" suffix when a
458 * future 'deprecated_on' is set), or null when the
459 * retirement date has passed and the entry must be
460 * hidden from NEW selections.
461 */
462 private static function picker_label($entry) {
463 if (empty($entry['deprecated_on'])) {
464 return $entry['label'];
465 }
466 $ts = strtotime($entry['deprecated_on'] . ' 00:00:00 UTC');
467 if ($ts !== false && time() >= $ts) {
468 return null; // retired — hide from new selections
469 }
470 return sprintf(
471 /* translators: 1: model label, 2: retirement date */
472 __('%1$s — retires %2$s', 'mxchat'),
473 $entry['label'],
474 date_i18n(get_option('date_format', 'F j, Y'), $ts)
475 );
476 }
477
478 /**
479 * Settings-dropdown-shaped chat model map keyed by group label, which is
480 * what mxchat_model_callback() in class-mxchat-admin.php renders as
481 * <optgroup>. Each model maps to its label-with-description string.
482 *
483 * Retired models (past 'deprecated_on') are hidden — EXCEPT the install's
484 * currently saved model, which is re-injected with a "retired" marker so
485 * the settings screen never renders a blank select (plan e46b8f).
486 *
487 * @param string $current_model Optionally, the install's saved model id.
488 * @param array|null $provider_slugs Optional allowlist of provider slugs
489 * (catalog keys, e.g. 'openai', 'claude').
490 * null = every provider. Lets a consumer
491 * that can only dispatch to some providers
492 * (the content generator — plan ccb751)
493 * derive its picker from the same catalog.
494 */
495 public static function settings_dropdown_groups($current_model = '', $provider_slugs = null) {
496 $out = array();
497 foreach (self::chat_models() as $provider_slug => $provider) {
498 if (is_array($provider_slugs) && !in_array($provider_slug, $provider_slugs, true)) {
499 continue;
500 }
501 $out[$provider['label']] = array();
502 foreach ($provider['models'] as $id => $entry) {
503 $label = self::picker_label($entry);
504 if ($label === null && $id !== $current_model) {
505 continue;
506 }
507 if ($label === null) {
508 /* translators: %s: model label */
509 $label = sprintf(__('%s — retired by provider, choose a replacement', 'mxchat'), $entry['label']);
510 }
511 $out[$provider['label']][$id] = $label;
512 }
513 }
514 return $out;
515 }
516
517 /**
518 * Shape used by js/mxchat-admin.js's modal picker grid: provider slug =>
519 * array of { value, label, description }. Mirrors the original inline
520 * `models = {…}` literal in js/mxchat-admin.js so the modal can read from
521 * a localized PHP source without changing its render path.
522 */
523 public static function js_picker_shape() {
524 $out = array();
525 foreach (self::chat_models() as $provider_slug => $provider) {
526 $out[$provider_slug] = array();
527 foreach ($provider['models'] as $id => $entry) {
528 $label = self::picker_label($entry);
529 if ($label === null) {
530 continue; // retired — never offered as a new selection
531 }
532 $out[$provider_slug][] = array(
533 'value' => $id,
534 'label' => $label,
535 'description' => $entry['description'],
536 );
537 }
538 }
539 return $out;
540 }
541
542 /**
543 * Shape used by the Onboarding wizard's JS catalog (per-provider
544 * `{ label, chatModels, embeddingModels, hasKey, requiresKeyToLoadModels }`).
545 * `hasKey` is filled in by the caller after consulting mxchat_options.
546 */
547 public static function onboarding_js_catalog() {
548 $chat = self::chat_models();
549 $emb = self::embedding_models();
550 $out = array();
551
552 $all_slugs = array_unique(array_merge(array_keys($chat), array_keys($emb)));
553 foreach ($all_slugs as $slug) {
554 $chat_models = array();
555 if (isset($chat[$slug])) {
556 foreach ($chat[$slug]['models'] as $id => $entry) {
557 $label = self::picker_label($entry);
558 if ($label === null) {
559 continue; // retired — not offered during onboarding
560 }
561 $chat_models[$id] = $label;
562 }
563 }
564 $embed_models = array();
565 if (isset($emb[$slug])) {
566 foreach ($emb[$slug]['models'] as $id => $entry) {
567 $embed_models[$id] = $entry['label'];
568 }
569 }
570 $label = self::provider_label($slug);
571 $requires_key = isset($chat[$slug]) ? !empty($chat[$slug]['requires_key_to_load_models']) : false;
572 $disable_onb = isset($chat[$slug]) ? !empty($chat[$slug]['disable_in_onboarding']) : false;
573
574 $out[$slug] = array(
575 'label' => $label,
576 'chatModels' => $chat_models,
577 'embeddingModels' => $embed_models,
578 'requiresKeyToLoadModels' => $requires_key,
579 'disableInOnboarding' => ($disable_onb || $requires_key),
580 'keyOption' => self::key_option_for_provider($slug),
581 );
582 }
583 return $out;
584 }
585
586 /**
587 * Whether the given chat model can do model-driven function calling (tool use).
588 *
589 * Used by the native function-calling loop (plan-mxchat-20260617-a41dee) to
590 * decide whether to offer tools to the model and to gate the admin toggle.
591 *
592 * Design: every modern flagship across the catalog's providers supports tool
593 * calling, so the DEFAULT is "capable" for any model whose id matches a known
594 * provider family prefix (or the OpenRouter meta). This deliberately avoids a
595 * per-model allowlist that would go stale the moment the catalog gains a model
596 * (the same drift trap documented in CLAUDE.md's model-registry checklist).
597 * A model can be force-excluded via the $no_tools list, and the whole verdict
598 * is overridable through the `mxchat_model_supports_tools` filter.
599 *
600 * @param string $model_id Chat model id (e.g. 'gpt-5.5', 'claude-opus-4-8') or
601 * the 'openrouter' meta selector.
602 * @return bool
603 */
604 public static function supports_tools($model_id) {
605 $model_id = (string) $model_id;
606
607 // The OpenRouter meta resolves to a per-account sub-model at request time;
608 // most routable models support tools, so default it capable (the loop
609 // no-ops gracefully if the chosen sub-model rejects a tools array).
610 if ($model_id === 'openrouter') {
611 return (bool) apply_filters('mxchat_model_supports_tools', true, $model_id);
612 }
613
614 // Known tool-capable provider family prefixes across the catalog.
615 $capable_prefixes = array(
616 'gpt-', 'o1-', 'o3-', 'o4-', // OpenAI
617 'claude-', // Anthropic
618 'gemini-', // Google
619 'grok-', 'xai-', // xAI
620 'deepseek-', // DeepSeek (OpenAI-compatible tools)
621 'custom-', // OpenAI-compatible custom endpoints
622 );
623
624 // Explicit exclusions for any catalog model known NOT to support tools.
625 // Empty today — kept as the drift-safe escape hatch.
626 $no_tools = array();
627
628 $supported = false;
629 if (!in_array($model_id, $no_tools, true)) {
630 foreach ($capable_prefixes as $prefix) {
631 if (strpos($model_id, $prefix) === 0) {
632 $supported = true;
633 break;
634 }
635 }
636 }
637
638 return (bool) apply_filters('mxchat_model_supports_tools', $supported, $model_id);
639 }
640
641 /**
642 * The catalog's CURRENT recommended model for image analysis, per provider.
643 *
644 * plan-mxchat-20260717-eb371e: add-ons that need "a vision-capable model"
645 * (mxchat-vision's analysis paths, mxchat-admin-chat's in-admin image
646 * analysis) previously froze a model id at their own call sites — and those
647 * pins silently aged out (mxchat-vision was still sending gpt-4o). This is
648 * the ONE place that opinion lives now; add-ons ask, with their old pin
649 * demoted to a fallback for when mxchat-basic is absent or too old.
650 *
651 * Choices are deliberate:
652 * - openai: gpt-5.6-sol — the current flagship, vision-capable; OpenAI's
653 * stated replacement for the gpt-5.x-chat-latest aliases retired
654 * 2026-08-10 (which this map previously pinned; plan e46b8f).
655 * - claude: the current Haiku — image analysis wants fast + cheap; Haiku
656 * is vision-capable and the catalog's newest Haiku generation.
657 * - xai: grok-4.6, the current flagship. Promoted 2026-08-14 (plan
658 * e1aa4a) from grok-4-0709, which was two generations behind and no
659 * longer appears in xAI's /v1/models listing. Image input is CONFIRMED,
660 * not assumed: a 32x32 fixture probe on 2026-08-14 returned the correct
661 * colour on grok-4.6 / 4.5 / 4.3 and both 4.20 variants (all HTTP 200).
662 * grok-4.3 is the cheaper vision option (roughly half the image-token
663 * price, 1M context) if cost ever outweighs capability here.
664 * - gemini: current stable Flash — vision-capable, no per-image surcharge.
665 *
666 * NOT covered: image-GENERATION models (Gemini Imagen / Flash Image etc.).
667 * Those are a different capability the catalog does not model; add-ons that
668 * pin one (mxchat-vision's editing endpoints) are correctly pinned.
669 *
670 * @param string $provider 'openai' | 'claude' | 'xai' | 'gemini'.
671 * @return string Model id, or '' for an unknown provider (caller keeps its
672 * fallback). Overridable via mxchat_default_vision_model.
673 */
674 public static function default_vision_model($provider) {
675 $map = array(
676 'openai' => 'gpt-5.6-sol',
677 'claude' => 'claude-haiku-4-5-20251001',
678 'xai' => 'grok-4.6',
679 'gemini' => 'gemini-3.8-flash',
680 );
681 $model = isset($map[$provider]) ? $map[$provider] : '';
682 return (string) apply_filters('mxchat_default_vision_model', $model, $provider);
683 }
684
685 /**
686 * Per-model, per-surface reasoning_effort value.
687 *
688 * plan-mxchat-20260714-dcb71c: the single source of truth for the
689 * reasoning_effort ladder that was previously hand-maintained in FIVE
690 * parallel places (integrator streaming/non-streaming chat, integrator
691 * web-search Responses, content-generator, mxchat-advanced-content
692 * class-ai-client + class-content-calendar). Adding a model to
693 * chat_models() and updating THIS method now propagates the routing to
694 * every surface — no more drift-on-every-model-add.
695 *
696 * The mapping is DELIBERATELY per-context because the surfaces genuinely
697 * differ today (they are NOT a single clamped ladder):
698 * - 'chat' — /v1/chat/completions main chat. gpt-5.5 and gpt-5.4 use
699 * 'none'; the *-chat-latest / 5.4-mini / 5.4-nano /
700 * gpt-5.2 ids send NO reasoning_effort; the original
701 * gpt-5 / gpt-5-mini / gpt-5-nano send 'minimal' (their
702 * generation requires it — 25b972 probe). Unknown future
703 * gpt-5* ids send NOTHING (allowlist fall-through).
704 * - 'content' — content generation (content-generator, AC ai-client,
705 * AC content-calendar). gpt-5.5 / gpt-5.4 use 'low';
706 * gpt-5.4-mini / gpt-5.4-nano use 'none' (they never
707 * supported 'minimal' — 25b972); gpt-5.3-chat-latest and
708 * the original gpt-5 / -mini / -nano use 'minimal'; only
709 * gpt-5.2 and gpt-5.1-chat-latest send nothing. This is
710 * an ALLOWLIST (5621dd safe-default): a future gpt-5* id
711 * not listed gets NO param rather than a guessed value
712 * that might 400.
713 * - 'websearch' — /v1/responses web-search. Only the gpt-5.6 trio, 5.5,
714 * 5.4, and 5.1-2025-11-13 send 'low'; every other gpt-5 id
715 * (including gpt-5 / mini / nano / chat-latest) sends
716 * nothing.
717 *
718 * @param string $model Chat model id.
719 * @param string $context 'chat' | 'content' | 'websearch'. Defaults to 'chat'.
720 * @return string|null 'none'|'low'|'minimal' to send, or null to omit the
721 * reasoning_effort / reasoning.effort param entirely.
722 */
723 public static function reasoning_effort_for($model, $context = 'chat') {
724 $model = (string) $model;
725
726 // plan a9774b: every Gemini 3.x output price is "including thinking
727 // tokens" and the model thinks at its default level unless told
728 // otherwise — measured on this site's own support prompt, 71% of
729 // billed output on 3.8 Flash was hidden thinking, with indistinguishable
730 // replies at 'low' (0 hidden tokens). So the chat surface sends 'low':
731 // as reasoning_effort on the OpenAI-compatible endpoint, and as
732 // generationConfig.thinkingConfig.thinkingLevel on the native one (see
733 // gemini_thinking_config()). Flash-Lite does not think and returns 400
734 // on any reasoning parameter — it must send nothing.
735 if (strpos($model, 'gemini-3') === 0) {
736 if (strpos($model, 'flash-lite') !== false) {
737 return null;
738 }
739 return ($context === 'chat') ? 'low' : null;
740 }
741
742 // Only the gpt-5 family carries reasoning_effort. Everything else omits.
743 if (strpos($model, 'gpt-5') !== 0) {
744 return null;
745 }
746
747 switch ($context) {
748 case 'websearch':
749 // integrator web-search (Responses API). Explicit allowlist, no
750 // "else" — anything not listed omits reasoning entirely.
751 $map = array(
752 'gpt-5.6-sol' => 'low',
753 'gpt-5.6-terra' => 'low',
754 'gpt-5.6-luna' => 'low',
755 'gpt-5.5' => 'low',
756 'gpt-5.4' => 'low',
757 'gpt-5.1-2025-11-13' => 'low',
758 );
759 return isset($map[$model]) ? $map[$model] : null;
760
761 case 'content':
762 // content-generator + AC class-ai-client + AC content-calendar.
763 // ALLOWLIST (5621dd): a gpt-5* id absent here sends nothing.
764 //
765 // plan-25b972 (probed live 2026-08-13): supported reasoning_effort
766 // VALUES are per-model. gpt-5.4-mini/nano never supported
767 // 'minimal' (their floor is 'none' — the old 'minimal' entries
768 // 400'd on every call); the original gpt-5/-mini/-nano still
769 // REQUIRE 'minimal' and reject 'none'. Do not "harmonize" these
770 // — the two generations genuinely differ.
771 $map = array(
772 'gpt-5.6-sol' => 'low',
773 'gpt-5.6-terra' => 'low',
774 'gpt-5.6-luna' => 'low',
775 'gpt-5.5' => 'low',
776 'gpt-5.4' => 'low',
777 'gpt-5.1-2025-11-13' => 'low',
778 'gpt-5.4-mini' => 'none',
779 'gpt-5.4-nano' => 'none',
780 'gpt-5.3-chat-latest' => 'minimal',
781 'gpt-5' => 'minimal',
782 'gpt-5-mini' => 'minimal',
783 'gpt-5-nano' => 'minimal',
784 );
785 return isset($map[$model]) ? $map[$model] : null;
786
787 case 'chat':
788 default:
789 // integrator streaming + non-streaming main chat.
790 // gpt-5.1/5.3-chat-latest stay listed here (and in 'content')
791 // even though the catalog no longer offers them: bot-level /
792 // add-on-saved ids that miss the e46b8f migration must keep
793 // routing correctly until every surface is swept.
794 $no_reasoning = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
795 if (in_array($model, $no_reasoning, true)) {
796 return null;
797 }
798 if ($model === 'gpt-5.1-2025-11-13') {
799 return 'low';
800 }
801 if ($model === 'gpt-5.5') {
802 return 'none';
803 }
804 if ($model === 'gpt-5.4') {
805 return 'none';
806 }
807 if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) {
808 return 'low';
809 }
810 // plan-25b972: the original gpt-5 generation REQUIRES 'minimal'
811 // (probed 2026-08-13: 'none' is rejected for these three).
812 if (in_array($model, array('gpt-5', 'gpt-5-mini', 'gpt-5-nano'), true)) {
813 return 'minimal';
814 }
815 // plan-25b972: allowlist fall-through (5621dd philosophy). An
816 // unknown future gpt-5* id sends NO param — a guessed value is
817 // exactly how the 5.4-mini/nano 400s shipped.
818 return null;
819 }
820 }
821
822 /**
823 * Whether a NON-DEFAULT temperature value may be sent to this model.
824 *
825 * plan-mxchat-20260714-dcb71c: consolidates the scattered temperature
826 * omissions. Two families reject a custom temperature:
827 * - gpt-5* — accept only the default (1); any other value 400s. Callers
828 * that send temperature=1 (the main chat path) need not consult this —
829 * 1 is the default and is accepted. Callers that send a NON-default
830 * value (content-generator 0.7, AC 0.7) gate on this and omit for gpt-5.
831 * - The Anthropic flagships that removed the temperature param entirely
832 * (claude-opus-4-7 / 4-8, claude-fable-5, claude-sonnet-5) — 400 if sent
833 * at all. This is the list the integrator's mxchat_claude_omits_temperature()
834 * and content-generator's call_claude() have hand-maintained.
835 *
836 * @param string $model Chat model id.
837 * @return bool true if a custom temperature may be sent.
838 */
839 /**
840 * generationConfig.thinkingConfig for the native Gemini endpoint, or null
841 * to send none (plan a9774b). Derived from reasoning_effort_for() so the
842 * two Gemini paths — native generateContent and the OpenAI-compatible
843 * endpoint — always agree on the thinking level. Overridable via
844 * mxchat_gemini_thinking_level (return '' to send none).
845 *
846 * @param string $model Chat model id.
847 * @return array|null e.g. array('thinkingLevel' => 'low').
848 */
849 public static function gemini_thinking_config($model) {
850 $level = self::reasoning_effort_for($model, 'chat');
851 if (strpos((string) $model, 'gemini-') !== 0) {
852 return null;
853 }
854 $level = apply_filters('mxchat_gemini_thinking_level', $level, $model);
855 if (!is_string($level) || $level === '' || !in_array($level, array('minimal', 'low', 'medium', 'high'), true)) {
856 return null;
857 }
858 return array('thinkingLevel' => $level);
859 }
860
861 public static function supports_temperature($model) {
862 $model = (string) $model;
863
864 // gpt-5 family accepts only the default temperature (1).
865 if (strpos($model, 'gpt-5') === 0) {
866 return false;
867 }
868
869 // Anthropic flagships that reject the temperature param outright.
870 // TRAP: exact-match list — every NEW Anthropic flagship (4.7 and newer)
871 // must be added here or it 400s on every chat message while looking
872 // fine in the picker. A claude-opus-* prefix rule would wrongly catch
873 // claude-opus-4-6 and older, which still accept temperature.
874 $claude_no_temp = array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5');
875 if (in_array($model, $claude_no_temp, true)) {
876 return false;
877 }
878
879 return true;
880 }
881
882 /**
883 * The completion-token parameter key an OpenAI /v1/chat/completions call
884 * must use for this model. gpt-5* require 'max_completion_tokens'; the
885 * legacy 'max_tokens' 400s. All other OpenAI-compatible providers
886 * (xAI, DeepSeek, custom) use 'max_tokens'.
887 *
888 * plan-mxchat-20260714-dcb71c: centralizes the `$is_gpt5 ?
889 * 'max_completion_tokens' : 'max_tokens'` switch duplicated in
890 * content-generator and AC class-ai-client.
891 *
892 * @param string $model Chat model id.
893 * @return string 'max_completion_tokens' | 'max_tokens'
894 */
895 public static function openai_token_param($model) {
896 return strpos((string) $model, 'gpt-5') === 0 ? 'max_completion_tokens' : 'max_tokens';
897 }
898 }
899