| @@ -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; |