PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / trunk
MxChat – AI Chatbot & Content Generation for WordPress vtrunk
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 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | includes/class-mxchat-content-generator.php +125 -34 3.2.13trunk 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,23 @@
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) return null;
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-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
2294 + return 'minimal';
2295 + }
2296 +
2268 2297 private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) {
2269 2298 if (empty($api_key)) {
2270 2299 return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat'));
2271 2300 }
@@ -2278,11 +2307,17 @@
2278 2307 'content' => $msg['content'] ?? '',
2279 2308 );
2280 2309 }
2281 2310
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';
2311 + // GPT-5.x models require max_completion_tokens and only support temperature=1.
2312 + // Token key, temperature gate, and reasoning_effort are sourced from the
2313 + // core model catalog (plan-dcb71c); frozen fallbacks preserve exact
2314 + // pre-dcb71c behavior if the catalog class is unavailable.
2315 + $is_gpt5 = strpos($model, 'gpt-5') === 0;
2316 + $catalog = class_exists('MxChat_Model_Catalog');
2317 + $token_key = ($catalog && method_exists('MxChat_Model_Catalog', 'openai_token_param'))
2318 + ? MxChat_Model_Catalog::openai_token_param($model)
2319 + : ($is_gpt5 ? 'max_completion_tokens' : 'max_tokens');
2285 2320
2286 2321 $body = array(
2287 2322 'model' => $model,
2288 2323 'messages' => $formatted,
@@ -2289,27 +2324,30 @@
2289 2324 $token_key => $max_tokens,
2290 2325 'stream' => false,
2291 2326 );
2292 2327
2293 - if (!$is_gpt5) {
2328 + $temp_ok = ($catalog && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2329 + ? MxChat_Model_Catalog::supports_temperature($model)
2330 + : !$is_gpt5;
2331 + if ($temp_ok) {
2294 2332 $body['temperature'] = 0.7;
2295 2333 }
2296 2334
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 - }
2335 + $effort = ($catalog && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for'))
2336 + ? MxChat_Model_Catalog::reasoning_effort_for($model, 'content')
2337 + : $this->mxchat_cg_reasoning_effort_fallback($model);
2338 + if ($effort !== null) {
2339 + $body['reasoning_effort'] = $effort;
2310 2340 }
2311 2341
2342 + // DeepSeek V4 defaults to thinking mode ON (temperature ignored and
2343 + // reasoning eats the token budget); generation wants the legacy
2344 + // deepseek-chat semantics = non-thinking. Endpoint-gated because this
2345 + // helper is shared with OpenAI and xAI.
2346 + if (strpos($endpoint, 'api.deepseek.com') !== false) {
2347 + $body['thinking'] = array('type' => 'disabled');
2348 + }
2349 +
2312 2350 // Scale timeout with token count — large generation calls need more time
2313 2351 $timeout = ($max_tokens > 8000) ? 300 : 120;
2314 2352
2315 2353 $response = wp_remote_post($endpoint, array(
@@ -2327,8 +2365,38 @@
2327 2365
2328 2366 $status_code = wp_remote_retrieve_response_code($response);
2329 2367 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2330 2368
2369 + // plan-25b972 self-heal: supported reasoning_effort VALUES are per-model;
2370 + // a 400 rejecting the value we sent (stale catalog entry / provider
2371 + // drift) is deterministic — strip the param and retry ONCE.
2372 + if ($status_code === 400
2373 + && isset($body['reasoning_effort'])
2374 + && isset($decoded['error']['message'])
2375 + && is_string($decoded['error']['message'])
2376 + && preg_match('/Unsupported value:.*reasoning_effort/i', $decoded['error']['message'])) {
2377 + if (defined('WP_DEBUG') && WP_DEBUG) {
2378 + error_log(sprintf(
2379 + '[MxChat] content-generator: model %s rejected reasoning_effort \'%s\' — retrying once without the param (plan-25b972).',
2380 + $model, $body['reasoning_effort']
2381 + ));
2382 + }
2383 + unset($body['reasoning_effort']);
2384 + $response = wp_remote_post($endpoint, array(
2385 + 'headers' => array(
2386 + 'Authorization' => 'Bearer ' . $api_key,
2387 + 'Content-Type' => 'application/json',
2388 + ),
2389 + 'body' => wp_json_encode($body),
2390 + 'timeout' => $timeout,
2391 + ));
2392 + if (is_wp_error($response)) {
2393 + return $response;
2394 + }
2395 + $status_code = wp_remote_retrieve_response_code($response);
2396 + $decoded = json_decode(wp_remote_retrieve_body($response), true);
2397 + }
2398 +
2331 2399 if ($status_code !== 200) {
2332 2400 $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code;
2333 2401 return new WP_Error('api_error', $error_msg);
2334 2402 }
@@ -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;
@@ -3284,23 +3359,39 @@
3284 3359 array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
3285 3360 );
3286 3361 }
3287 3362
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.
3363 + // When sorting by a meta field, posts WITHOUT the key must stay in the
3364 + // list — GSC meta is no longer zero-filled for no-data posts (plan
3365 + // 282c80), and score meta never existed for unanalyzed posts. A single
3366 + // OR EXISTS/NOT-EXISTS meta_query cannot order this reliably (under an
3367 + // OR relation WP_Meta_Query leaves the orderby clause's join alias
3368 + // unconstrained by meta_key, so ORDER BY reads arbitrary meta rows).
3369 + // Instead: two id-only queries — posts WITH the key sorted by its
3370 + // value, then posts WITHOUT it appended (newest first). Missing-data
3371 + // rows deliberately sort last in BOTH directions.
3290 3372 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 - );
3373 + $with_args = $args;
3374 + $with_args['meta_key'] = $sort_meta_key;
3375 + $with_args['orderby'] = 'meta_value_num';
3376 + $q_with = new \WP_Query($with_args);
3377 +
3378 + $without_args = $args;
3379 + $missing_clause = array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS');
3380 + if (!empty($without_args['meta_query'])) {
3381 + $without_args['meta_query'] = array('relation' => 'AND', $without_args['meta_query'], $missing_clause);
3382 + } else {
3383 + $without_args['meta_query'] = array($missing_clause);
3298 3384 }
3385 + $without_args['orderby'] = 'date';
3386 + $without_args['order'] = 'DESC';
3387 + $q_without = new \WP_Query($without_args);
3388 +
3389 + $all_ids = array_merge($q_with->posts, $q_without->posts);
3390 + } else {
3391 + $query = new \WP_Query($args);
3392 + $all_ids = $query->posts;
3299 3393 }
3300 -
3301 - $query = new \WP_Query($args);
3302 - $all_ids = $query->posts;
3303 3394 $total = count($all_ids);
3304 3395 $pages = max(1, ceil($total / $per_page));
3305 3396 $page = min($page, $pages);
3306 3397 $offset = ($page - 1) * $per_page;