PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | includes/class-mxchat-content-generator.php +147 -39 3.2.13 → 3.2.25 View file →
@@ -2242,11 +2242,25 @@
2242 2242
2243 2243 /**
2244 2244 * Call the configured content model
2245 2245 */
2246 + /**
2247 + * Provider slugs (MxChat_Model_Catalog keys) that call_content_model() can
2248 + * dispatch to. MUST mirror the if/elseif chain below — the Content Model
2249 + * picker derives its provider set from this list (plan ccb751), so a
2250 + * provider added to the dispatch without updating this is never offered,
2251 + * and one added here without dispatch support would break generation.
2252 + * OpenRouter and the custom OpenAI-compatible provider are absent on
2253 + * purpose: their pseudo-model ids would fall through to the OpenAI default
2254 + * branch with the wrong key and endpoint.
2255 + */
2256 + public static function supported_content_providers() {
2257 + return array('openai', 'claude', 'gemini', 'xai', 'deepseek');
2258 + }
2259 +
2246 2260 private function call_content_model($system_prompt, $messages, $max_tokens = 4096) {
2247 2261 $options = get_option('mxchat_options', array());
2248 - $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.1-chat-latest';
2262 + $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.6-sol';
2249 2263
2250 2264 // Determine provider from model name
2251 2265 if ($this->is_claude_model($model)) {
2252 2266 return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
@@ -2264,8 +2278,24 @@
2264 2278
2265 2279 /**
2266 2280 * Call OpenAI-compatible API (OpenAI, xAI, DeepSeek)
2267 2281 */
2282 + /**
2283 + * plan-mxchat-20260714-dcb71c: frozen pre-dcb71c 'content' reasoning ladder,
2284 + * used only when the core model catalog is unavailable. Byte-identical to the
2285 + * old inline if/elseif at the call site.
2286 + */
2287 + private function mxchat_cg_reasoning_effort_fallback($model) {
2288 + if (strpos($model, 'gpt-5') !== 0 && strpos($model, 'gpt-6') !== 0) return null; // gpt-6 too (plan 45c22d)
2289 + if ($model === 'gpt-5.2' || $model === 'gpt-5.1-chat-latest') return null;
2290 + if ($model === 'gpt-5.1-2025-11-13') return 'low';
2291 + if ($model === 'gpt-5.5') return 'low';
2292 + if ($model === 'gpt-5.4') return 'low';
2293 + if (in_array($model, array('gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'), true)) return 'low'; // plan 45c22d
2294 + if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
2295 + return 'minimal';
2296 + }
2297 +
2268 2298 private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) {
2269 2299 if (empty($api_key)) {
2270 2300 return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat'));
2271 2301 }
@@ -2278,11 +2308,17 @@
2278 2308 'content' => $msg['content'] ?? '',
2279 2309 );
2280 2310 }
2281 2311
2282 - // GPT-5.x models require max_completion_tokens and only support temperature=1
2283 - $is_gpt5 = strpos($model, 'gpt-5') === 0;
2284 - $token_key = $is_gpt5 ? 'max_completion_tokens' : 'max_tokens';
2312 + // GPT-5.x models require max_completion_tokens and only support temperature=1.
2313 + // Token key, temperature gate, and reasoning_effort are sourced from the
2314 + // core model catalog (plan-dcb71c); frozen fallbacks preserve exact
2315 + // pre-dcb71c behavior if the catalog class is unavailable.
2316 + $is_gpt5 = strpos($model, 'gpt-5') === 0 || strpos($model, 'gpt-6') === 0; // gpt-6 too (plan 45c22d)
2317 + $catalog = class_exists('MxChat_Model_Catalog');
2318 + $token_key = ($catalog && method_exists('MxChat_Model_Catalog', 'openai_token_param'))
2319 + ? MxChat_Model_Catalog::openai_token_param($model)
2320 + : ($is_gpt5 ? 'max_completion_tokens' : 'max_tokens');
2285 2321
2286 2322 $body = array(
2287 2323 'model' => $model,
2288 2324 'messages' => $formatted,
@@ -2289,27 +2325,30 @@
2289 2325 $token_key => $max_tokens,
2290 2326 'stream' => false,
2291 2327 );
2292 2328
2293 - if (!$is_gpt5) {
2329 + $temp_ok = ($catalog && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2330 + ? MxChat_Model_Catalog::supports_temperature($model)
2331 + : !$is_gpt5;
2332 + if ($temp_ok) {
2294 2333 $body['temperature'] = 0.7;
2295 2334 }
2296 2335
2297 - // Add reasoning_effort only for GPT-5 models that support it
2298 - // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
2299 - if ($is_gpt5 && $model !== 'gpt-5.2' && $model !== 'gpt-5.1-chat-latest') {
2300 - // GPT-5.1 uses 'low' instead of 'minimal'
2301 - if ($model === 'gpt-5.1-2025-11-13') {
2302 - $body['reasoning_effort'] = 'low';
2303 - } elseif ($model === 'gpt-5.5') {
2304 - $body['reasoning_effort'] = 'low';
2305 - } elseif ($model === 'gpt-5.4') {
2306 - $body['reasoning_effort'] = 'low';
2307 - } else {
2308 - $body['reasoning_effort'] = 'minimal';
2309 - }
2336 + $effort = ($catalog && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for'))
2337 + ? MxChat_Model_Catalog::reasoning_effort_for($model, 'content')
2338 + : $this->mxchat_cg_reasoning_effort_fallback($model);
2339 + if ($effort !== null) {
2340 + $body['reasoning_effort'] = $effort;
2310 2341 }
2311 2342
2343 + // DeepSeek V4 defaults to thinking mode ON (temperature ignored and
2344 + // reasoning eats the token budget); generation wants the legacy
2345 + // deepseek-chat semantics = non-thinking. Endpoint-gated because this
2346 + // helper is shared with OpenAI and xAI.
2347 + if (strpos($endpoint, 'api.deepseek.com') !== false) {
2348 + $body['thinking'] = array('type' => 'disabled');
2349 + }
2350 +
2312 2351 // Scale timeout with token count — large generation calls need more time
2313 2352 $timeout = ($max_tokens > 8000) ? 300 : 120;
2314 2353
2315 2354 $response = wp_remote_post($endpoint, array(
@@ -2327,8 +2366,38 @@
2327 2366
2328 2367 $status_code = wp_remote_retrieve_response_code($response);
2329 2368 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2330 2369
2370 + // plan-25b972 self-heal: supported reasoning_effort VALUES are per-model;
2371 + // a 400 rejecting the value we sent (stale catalog entry / provider
2372 + // drift) is deterministic — strip the param and retry ONCE.
2373 + if ($status_code === 400
2374 + && isset($body['reasoning_effort'])
2375 + && isset($decoded['error']['message'])
2376 + && is_string($decoded['error']['message'])
2377 + && preg_match('/Unsupported value:.*reasoning_effort/i', $decoded['error']['message'])) {
2378 + if (defined('WP_DEBUG') && WP_DEBUG) {
2379 + error_log(sprintf(
2380 + '[MxChat] content-generator: model %s rejected reasoning_effort \'%s\' — retrying once without the param (plan-25b972).',
2381 + $model, $body['reasoning_effort']
2382 + ));
2383 + }
2384 + unset($body['reasoning_effort']);
2385 + $response = wp_remote_post($endpoint, array(
2386 + 'headers' => array(
2387 + 'Authorization' => 'Bearer ' . $api_key,
2388 + 'Content-Type' => 'application/json',
2389 + ),
2390 + 'body' => wp_json_encode($body),
2391 + 'timeout' => $timeout,
2392 + ));
2393 + if (is_wp_error($response)) {
2394 + return $response;
2395 + }
2396 + $status_code = wp_remote_retrieve_response_code($response);
2397 + $decoded = json_decode(wp_remote_retrieve_body($response), true);
2398 + }
2399 +
2331 2400 if ($status_code !== 200) {
2332 2401 $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code;
2333 2402 return new WP_Error('api_error', $error_msg);
2334 2403 }
@@ -2344,11 +2413,10 @@
2344 2413 * Call Claude (Anthropic) API
2345 2414 */
2346 2415 private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) {
2347 2416 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
2348 - // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
2349 - if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
2350 - elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
2417 + // Read-time rescue: a saved retired id is remapped to the catalog's replacement before the API call.
2418 + $model = MxChat_Model_Catalog::resolve_retired($model); // plan 8c4eb4: the catalog's one map
2351 2419 if (empty($api_key)) {
2352 2420 return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat'));
2353 2421 }
2354 2422
@@ -2368,15 +2436,22 @@
2368 2436 'model' => $model,
2369 2437 'max_tokens' => $max_tokens,
2370 2438 'temperature' => 0.7,
2371 2439 'messages' => $formatted,
2372 - 'system' => $system_prompt,
2440 + // Prompt-cache breakpoint (plan 1ff43b): generation pipelines reuse
2441 + // the same system prompt across consecutive requests within the
2442 + // 5-minute cache window. Silently no-ops below the model minimum.
2443 + 'system' => trim((string) $system_prompt) === '' ? $system_prompt : array(
2444 + array('type' => 'text', 'text' => $system_prompt, 'cache_control' => array('type' => 'ephemeral')),
2445 + ),
2373 2446 );
2374 2447
2375 2448 // Anthropic removed temperature on Opus 4.7+ flagships (400 if sent) —
2376 - // same list as the integrator's mxchat_claude_omits_temperature().
2377 - $no_temp = array('claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5');
2378 - if (in_array($model, $no_temp, true)) {
2449 + // sourced from the core model catalog (plan-dcb71c); frozen list fallback.
2450 + $omit_temp = (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2451 + ? !MxChat_Model_Catalog::supports_temperature($model)
2452 + : in_array($model, array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5'), true);
2453 + if ($omit_temp) {
2379 2454 unset($body['temperature']);
2380 2455 }
2381 2456
2382 2457 $timeout = ($max_tokens > 8000) ? 300 : 120;
@@ -2480,12 +2555,29 @@
2480 2555 $error_msg = $decoded['error']['message'] ?? __('Gemini API error ', 'mxchat') . $status_code;
2481 2556 return new WP_Error('gemini_error', $error_msg);
2482 2557 }
2483 2558
2484 - if (isset($decoded['candidates'][0]['content']['parts'][0]['text'])) {
2485 - return trim($decoded['candidates'][0]['content']['parts'][0]['text']);
2559 + // plan 781afd: join every text part (skip thought parts), not only parts[0].
2560 + $text = '';
2561 + if (isset($decoded['candidates'][0]['content']['parts']) && is_array($decoded['candidates'][0]['content']['parts'])) {
2562 + foreach ($decoded['candidates'][0]['content']['parts'] as $part) {
2563 + if (is_array($part) && isset($part['text']) && is_string($part['text']) && empty($part['thought'])) {
2564 + $text .= $part['text'];
2565 + }
2566 + }
2486 2567 }
2568 + $text = trim($text);
2569 + if ($text !== '') {
2570 + return $text;
2571 + }
2487 2572
2573 + $no_text_reason = isset($decoded['candidates'][0]['finishReason']) && is_string($decoded['candidates'][0]['finishReason'])
2574 + ? $decoded['candidates'][0]['finishReason']
2575 + : (isset($decoded['promptFeedback']['blockReason']) && is_string($decoded['promptFeedback']['blockReason']) ? $decoded['promptFeedback']['blockReason'] : '');
2576 + if ($no_text_reason !== '') {
2577 + /* translators: %s: Gemini's finish or block reason, e.g. SAFETY */
2578 + return new WP_Error('gemini_no_text', sprintf(__('Gemini returned no text (%s).', 'mxchat'), $no_text_reason));
2579 + }
2488 2580 return new WP_Error('unexpected_response', __('Unexpected Gemini response format.', 'mxchat'));
2489 2581 }
2490 2582
2491 2583 // ─── Model Detection Helpers ────────────────────────────────────────
@@ -3284,23 +3376,39 @@
3284 3376 array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
3285 3377 );
3286 3378 }
3287 3379
3288 - // When sorting by a meta field, ensure meta_key is set for ordering.
3289 - // For 'all' filter, include posts without the meta key via OR clause.
3380 + // When sorting by a meta field, posts WITHOUT the key must stay in the
3381 + // list — GSC meta is no longer zero-filled for no-data posts (plan
3382 + // 282c80), and score meta never existed for unanalyzed posts. A single
3383 + // OR EXISTS/NOT-EXISTS meta_query cannot order this reliably (under an
3384 + // OR relation WP_Meta_Query leaves the orderby clause's join alias
3385 + // unconstrained by meta_key, so ORDER BY reads arbitrary meta rows).
3386 + // Instead: two id-only queries — posts WITH the key sorted by its
3387 + // value, then posts WITHOUT it appended (newest first). Missing-data
3388 + // rows deliberately sort last in BOTH directions.
3290 3389 if ($sort_meta_key) {
3291 - $args['meta_key'] = $sort_meta_key;
3292 - if ($filter === 'all') {
3293 - $args['meta_query'] = array(
3294 - 'relation' => 'OR',
3295 - array('key' => $sort_meta_key, 'compare' => 'EXISTS'),
3296 - array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS'),
3297 - );
3390 + $with_args = $args;
3391 + $with_args['meta_key'] = $sort_meta_key;
3392 + $with_args['orderby'] = 'meta_value_num';
3393 + $q_with = new \WP_Query($with_args);
3394 +
3395 + $without_args = $args;
3396 + $missing_clause = array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS');
3397 + if (!empty($without_args['meta_query'])) {
3398 + $without_args['meta_query'] = array('relation' => 'AND', $without_args['meta_query'], $missing_clause);
3399 + } else {
3400 + $without_args['meta_query'] = array($missing_clause);
3298 3401 }
3402 + $without_args['orderby'] = 'date';
3403 + $without_args['order'] = 'DESC';
3404 + $q_without = new \WP_Query($without_args);
3405 +
3406 + $all_ids = array_merge($q_with->posts, $q_without->posts);
3407 + } else {
3408 + $query = new \WP_Query($args);
3409 + $all_ids = $query->posts;
3299 3410 }
3300 -
3301 - $query = new \WP_Query($args);
3302 - $all_ids = $query->posts;
3303 3411 $total = count($all_ids);
3304 3412 $pages = max(1, ceil($total / $per_page));
3305 3413 $page = min($page, $pages);
3306 3414 $offset = ($page - 1) * $per_page;