| 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 |
} |
| 947 |
|