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