gmdate('c'), 'label' => $label, ); if ($memory) { $payload['memory'] = array( 'current' => memory_get_usage(true), 'peak' => memory_get_peak_usage(true), ); } if (!empty($context)) { $payload['context'] = $context; } $encoded = wp_json_encode($payload); if ($encoded) { error_log('[AnswerRails][debug] ' . $encoded); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated debug channel sink (this IS the debug_log implementation) } } /** * The active AI provider id ('openai' | 'mistral' | 'anthropic'). * Single source of truth for provider-scoped option resolution. */ public static function get_active_provider(): string { return (string) get_option('answerrails_active_provider', 'openai'); } /** * Read a setting scoped to the active provider * (answerrails_provider_{id}_{setting}). * * @param string $setting Setting suffix, e.g. 'api_key', 'model', 'base'. * @param mixed $default Default when unset. * @return mixed */ public static function get_provider_option(string $setting, $default = '') { return get_option('answerrails_provider_' . self::get_active_provider() . '_' . $setting, $default); } /** * Standard API key resolution for the ACTIVE provider — use this on the * chat path. Provider-specific plumbing (e.g. OpenAI knowledge sync, the * admin OpenAI endpoint tests) must use get_api_key_for() instead, or it * would silently pick up another provider's key when the active provider * changes. */ public static function get_api_key(): string { return self::get_api_key_for(self::get_active_provider()); } /** * API key resolution for an EXPLICIT provider, regardless of which * provider is active. */ public static function get_api_key_for(string $provider): string { // 1. wp-config.php constant (production priority; OpenAI-specific) if ('openai' === $provider && defined('ANSWERRAILS_OPENAI_API_KEY') && ANSWERRAILS_OPENAI_API_KEY) { return trim(ANSWERRAILS_OPENAI_API_KEY); } // 2. Per-provider database option (standard setting) $key = (string) get_option('answerrails_provider_' . $provider . '_api_key', ''); if (!empty($key)) { return trim($key); } // 3. Environment variable (fallback; OpenAI-specific) if ('openai' === $provider) { $env_key = getenv('OPENAI_API_KEY'); return $env_key ? trim($env_key) : ''; } return ''; } /** * Per-provider model defaults. The fallback MUST be valid for the active * provider: activation defaults only seed options on fresh activations, * so an existing install that switches provider can hit an unset * model option (found the hard way on 2026-07-21, when a non-OpenAI * turn went out as "gpt-4o"). */ private const MODEL_DEFAULTS = array( // Each value must be a catalogue model with no RETIREMENTS row — // ModelCurrencyInvariantTest enforces it (#108). Refreshed by #332 // (catalogue verified 2026-10-25). 'openai' => 'gpt-6-luna', 'mistral' => 'mistral-small-latest', 'anthropic' => 'claude-sonnet-5-5', ); /** * The shipped default model id for a provider ('' for an unknown provider) — * the ONE source for every default-model site (#332 D2, #108). */ public static function default_model(string $provider): string { return self::MODEL_DEFAULTS[$provider] ?? ''; } /** * Get chat model with a provider-correct fallback. */ public static function get_model(): string { $provider = self::get_active_provider(); $default = self::default_model($provider); return (string) self::get_provider_option('model', $default); } /** * Resolve the real client IP behind a trusted proxy (#52) — the single * IP resolver (the divergent copies in rest-auth and core delegate here). * * Trust model: X-Forwarded-For is only honoured when the direct peer * (REMOTE_ADDR) is private/reserved — i.e. we were reached via our own * infrastructure (the c3d-web ALB in production, the local proxy in dev). * The chain is walked RIGHT to LEFT, taking the first public address: * the right-most entries were appended by our trusted hop(s), while * left-most entries are client-supplied and spoofable. With a direct * public connection, REMOTE_ADDR wins and XFF is ignored entirely. */ public static function get_client_ip(): string { // #331 D6: sanitise at the read. Behaviour-identical — every value // this function returns already had to pass FILTER_VALIDATE_IP, // which rejects anything sanitize_text_field would have altered — // and the phpcs:ignore that justified the omission is gone with it. $remote_addr = isset($_SERVER['REMOTE_ADDR']) ? trim(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']))) : ''; $remote_is_public = (bool) filter_var( $remote_addr, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); $behind_trusted_proxy = apply_filters('answerrails_behind_trusted_proxy', !$remote_is_public); if ($behind_trusted_proxy && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { // #331 D6: sanitised at the read, same reasoning as // REMOTE_ADDR above — each entry must still pass // FILTER_VALIDATE_IP before it can be returned. $chain = array_map( 'trim', explode(',', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']))) ); for ($i = count($chain) - 1; $i >= 0; $i--) { if (filter_var($chain[$i], FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return $chain[$i]; } } } return filter_var($remote_addr, FILTER_VALIDATE_IP) ? $remote_addr : ''; } /** * Whether a URL points at this site or one of its configured primary * domains (#72) — the single internal-vs-external test. Used by link * decoration, URL validation, and URL-type knowledge ingestion. */ public static function is_internal_url(string $url): bool { $url_host = wp_parse_url($url, PHP_URL_HOST); if (empty($url_host)) { return false; } $url_host = strtolower($url_host); $site_host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)); if ($site_host !== '' && ($url_host === $site_host || str_ends_with($url_host, '.' . $site_host))) { return true; } $primary_domains = get_option('answerrails_primary_domains', ''); if (!empty($primary_domains)) { foreach (array_filter(array_map('trim', explode(',', $primary_domains))) as $domain) { $domain = strtolower($domain); if ($url_host === $domain || str_ends_with($url_host, '.' . $domain)) { return true; } } } return false; } /** * Get temperature setting */ public static function get_temperature(): float { return (float) get_option('answerrails_chat_temperature', 0.4); } /** * Get max tokens setting */ public static function get_max_tokens(): int { return (int) get_option('answerrails_chat_max_tokens', 1600); } }