diff --git a/inc/Abilities/SettingsAbilities.php b/inc/Abilities/SettingsAbilities.php index a68456b0f..f01346eef 100644 --- a/inc/Abilities/SettingsAbilities.php +++ b/inc/Abilities/SettingsAbilities.php @@ -110,10 +110,14 @@ private function registerUpdateSettings(): void { 'description' => 'Per-mode provider/model overrides keyed by mode id', ), 'max_turns' => array( 'type' => 'integer' ), - 'wp_ai_client_connect_timeout' => array( + 'wp_ai_client_connect_timeout' => array( 'type' => 'number', 'description' => 'Connection timeout in seconds for wp-ai-client provider requests.', ), + 'wp_ai_client_request_timeout' => array( + 'type' => 'number', + 'description' => 'Full request timeout in seconds for wp-ai-client provider requests.', + ), 'disabled_tools' => array( 'type' => 'object' ), 'ai_provider_keys' => array( 'type' => 'object' ), 'queue_tuning' => array( @@ -357,7 +361,8 @@ public function executeGetSettings( array $input ): array { 'default_model' => $settings['default_model'] ?? '', 'mode_models' => $settings['mode_models'] ?? array(), 'max_turns' => $settings['max_turns'] ?? $defaults['max_turns'], - 'wp_ai_client_connect_timeout' => $settings['wp_ai_client_connect_timeout'] ?? $defaults['wp_ai_client_connect_timeout'], + 'wp_ai_client_connect_timeout' => $settings['wp_ai_client_connect_timeout'] ?? $defaults['wp_ai_client_connect_timeout'], + 'wp_ai_client_request_timeout' => $settings['wp_ai_client_request_timeout'] ?? $defaults['wp_ai_client_request_timeout'], 'disabled_tools' => $settings['disabled_tools'] ?? array(), 'ai_provider_keys' => $masked_keys, 'queue_tuning' => wp_parse_args( $settings['queue_tuning'] ?? array(), $defaults['queue_tuning'] ), @@ -463,8 +468,19 @@ public function executeUpdateSettings( array $input ): array { } if ( isset( $input['wp_ai_client_connect_timeout'] ) && is_numeric( $input['wp_ai_client_connect_timeout'] ) ) { - $all_settings['wp_ai_client_connect_timeout'] = max( 0.0, min( 300.0, (float) $input['wp_ai_client_connect_timeout'] ) ); - $handled_keys[] = 'wp_ai_client_connect_timeout'; + $all_settings['wp_ai_client_connect_timeout'] = max( + 0.0, + min( PluginSettings::MAX_WP_AI_CLIENT_CONNECT_TIMEOUT, (float) $input['wp_ai_client_connect_timeout'] ) + ); + $handled_keys[] = 'wp_ai_client_connect_timeout'; + } + + if ( isset( $input['wp_ai_client_request_timeout'] ) && is_numeric( $input['wp_ai_client_request_timeout'] ) ) { + $all_settings['wp_ai_client_request_timeout'] = max( + 0.0, + min( PluginSettings::MAX_WP_AI_CLIENT_REQUEST_TIMEOUT, (float) $input['wp_ai_client_request_timeout'] ) + ); + $handled_keys[] = 'wp_ai_client_request_timeout'; } if ( isset( $input['disabled_tools'] ) ) { diff --git a/inc/Api/Settings.php b/inc/Api/Settings.php index b40db2457..9cd3f2286 100644 --- a/inc/Api/Settings.php +++ b/inc/Api/Settings.php @@ -323,6 +323,7 @@ public static function handle_get_settings( $request ) { 'success' => true, 'data' => array( 'settings' => $result['settings'], + 'defaults' => $result['defaults'] ?? array(), 'global_tools' => $result['global_tools'], ), ) diff --git a/inc/Core/Admin/Settings/assets/react/components/tabs/GeneralTab.jsx b/inc/Core/Admin/Settings/assets/react/components/tabs/GeneralTab.jsx index b9ed293fa..3266fd6a8 100644 --- a/inc/Core/Admin/Settings/assets/react/components/tabs/GeneralTab.jsx +++ b/inc/Core/Admin/Settings/assets/react/components/tabs/GeneralTab.jsx @@ -11,7 +11,7 @@ import { useEffect } from '@wordpress/element'; /** - * Internal dependencies + * External dependencies */ import { useSettings, useUpdateSettings } from '@shared/queries/settings'; import { useFormState } from '@shared/hooks/useFormState'; @@ -24,6 +24,8 @@ const EMPTY_FORM = { file_retention_days: 7, chat_retention_days: 90, chat_ai_titles_enabled: true, + wp_ai_client_connect_timeout: 15, + wp_ai_client_request_timeout: 300, flows_per_page: 20, jobs_per_page: 50, queue_tuning: { @@ -65,6 +67,10 @@ const GeneralTab = () => { chunk_size: 10, chunk_delay: 30, }; + const transportDefaults = { + connectTimeout: data?.defaults?.wp_ai_client_connect_timeout ?? 15, + requestTimeout: data?.defaults?.wp_ai_client_request_timeout ?? 300, + }; const form = useFormState( { initialData: EMPTY_FORM, @@ -87,6 +93,10 @@ const GeneralTab = () => { data.settings.chat_retention_days ?? EMPTY_FORM.chat_retention_days, chat_ai_titles_enabled: data.settings.chat_ai_titles_enabled ?? EMPTY_FORM.chat_ai_titles_enabled, + wp_ai_client_connect_timeout: + data.settings.wp_ai_client_connect_timeout ?? transportDefaults.connectTimeout, + wp_ai_client_request_timeout: + data.settings.wp_ai_client_request_timeout ?? transportDefaults.requestTimeout, flows_per_page: data.settings.flows_per_page ?? EMPTY_FORM.flows_per_page, jobs_per_page: @@ -173,6 +183,70 @@ const GeneralTab = () => { + + AI connect timeout + +
+ + updateField( + 'wp_ai_client_connect_timeout', + clamp( + e.target.value, + 0, + 300, + transportDefaults.connectTimeout + ) + ) + } + min="0" + max="300" + className="small-text" + /> +

+ Seconds allowed to establish the provider + connection. Default:{ ' ' } + { transportDefaults.connectTimeout }. +

+
+ + + + + AI request timeout + +
+ + updateField( + 'wp_ai_client_request_timeout', + clamp( + e.target.value, + 0, + 900, + transportDefaults.requestTimeout + ) + ) + } + min="0" + max="900" + className="small-text" + /> +

+ Seconds allowed for the full non-streaming AI + response. Default:{ ' ' } + { transportDefaults.requestTimeout }. +

+
+ + + File retention (days) diff --git a/inc/Core/PluginSettings.php b/inc/Core/PluginSettings.php index a24d90d46..457b6ade3 100644 --- a/inc/Core/PluginSettings.php +++ b/inc/Core/PluginSettings.php @@ -20,8 +20,11 @@ class PluginSettings { - public const DEFAULT_MAX_TURNS = 25; - public const DEFAULT_WP_AI_CLIENT_CONNECT_TIMEOUT = 30.0; + public const DEFAULT_MAX_TURNS = 25; + public const DEFAULT_WP_AI_CLIENT_CONNECT_TIMEOUT = 15.0; + public const DEFAULT_WP_AI_CLIENT_REQUEST_TIMEOUT = 300.0; + public const MAX_WP_AI_CLIENT_CONNECT_TIMEOUT = 300.0; + public const MAX_WP_AI_CLIENT_REQUEST_TIMEOUT = 900.0; private static ?array $cache = null; private static array $agent_model_cache = array(); @@ -55,12 +58,13 @@ public static function getDefaultQueueTuning(): array { /** * Get centralized plugin defaults used by backend and admin UI. * - * @return array{max_turns:int,wp_ai_client_connect_timeout:float,queue_tuning:array{concurrent_batches:int,batch_size:int,time_limit:int,chunk_size:int,chunk_delay:int}} + * @return array{max_turns:int,wp_ai_client_connect_timeout:float,wp_ai_client_request_timeout:float,queue_tuning:array{concurrent_batches:int,batch_size:int,time_limit:int,chunk_size:int,chunk_delay:int}} */ public static function getDefaults(): array { return array( 'max_turns' => self::DEFAULT_MAX_TURNS, 'wp_ai_client_connect_timeout' => self::DEFAULT_WP_AI_CLIENT_CONNECT_TIMEOUT, + 'wp_ai_client_request_timeout' => self::DEFAULT_WP_AI_CLIENT_REQUEST_TIMEOUT, 'queue_tuning' => self::getDefaultQueueTuning(), ); } diff --git a/inc/Engine/AI/RequestBuilder.php b/inc/Engine/AI/RequestBuilder.php index 4bd36585f..9cff53f49 100644 --- a/inc/Engine/AI/RequestBuilder.php +++ b/inc/Engine/AI/RequestBuilder.php @@ -21,16 +21,6 @@ class RequestBuilder { - /** - * Default timeout for Data Machine wp-ai-client requests. - * - * WordPress AI Client defaults to 30 seconds, which is too short for - * non-streaming LLM requests with large prompts. Studio's cURL low-speed - * watchdog is longer than this; without raising the AI Client request - * timeout, the request-level cap wins first. - */ - private const DEFAULT_WP_AI_CLIENT_REQUEST_TIMEOUT = 300.0; - /** * Build standardized AI request for any execution mode. * @@ -63,10 +53,10 @@ public static function build( ) { WpAiClientCache::install(); - $assembled = self::assemble( $messages, $provider, $model, $tools, $mode, $payload ); - $request = $assembled['request']; - $provider_request = ProviderRequestAssembler::toProviderRequest( $request ); - $prompt_context = self::wpAiClientPromptContext( $request['messages'] ?? array() ); + $assembled = self::assemble( $messages, $provider, $model, $tools, $mode, $payload ); + $request = $assembled['request']; + $provider_request = ProviderRequestAssembler::toProviderRequest( $request ); + $prompt_context = self::wpAiClientPromptContext( $request['messages'] ?? array() ); if ( '' !== $prompt_context['prompt'] ) { $provider_request['prompt'] = $prompt_context['prompt']; } @@ -130,14 +120,16 @@ public static function build( return new \WP_Error( 'wp_ai_client_unavailable', $unavailable_reason ); } - $result = null; - $request_options = null; - $request_timeout = self::wpAiClientRequestTimeout( $mode, $provider, $model, $payload ); - $connect_timeout = self::wpAiClientConnectTimeout( $mode, $provider, $model, $payload, $request_timeout ); - $timeout_filter = static function ( $default_timeout ) use ( $request_timeout ) { + $result = null; + $request_options = null; + $transport_profile = self::wpAiClientTransportProfile( $mode, $provider, $model, $payload ); + $request_timeout = (float) $transport_profile['request_timeout']; + $connect_timeout = (float) $transport_profile['connect_timeout']; + $request_metadata['transport'] = $transport_profile; + $timeout_filter = static function ( $default_timeout ) use ( $request_timeout ) { return max( (float) $default_timeout, $request_timeout ); }; - $curl_filter = static function ( $handle ) use ( $request_timeout, $connect_timeout ) { + $curl_filter = static function ( $handle ) use ( $request_timeout, $connect_timeout ) { if ( defined( 'CURLOPT_CONNECTTIMEOUT' ) ) { curl_setopt( $handle, CURLOPT_CONNECTTIMEOUT, (int) ceil( $connect_timeout ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- WordPress exposes the cURL handle only through this hook. } @@ -154,6 +146,8 @@ public static function build( try { add_filter( 'wp_ai_client_default_request_timeout', $timeout_filter, 10, 1 ); add_action( 'http_api_curl', $curl_filter, 10, 1 ); + $transport_profile['curl_hook_installed'] = true; + $request_metadata['transport'] = $transport_profile; $registry = \WordPress\AiClient\AiClient::defaultRegistry(); /** @var callable $has_provider wp-ai-client exposes this through __call() in some versions. */ @@ -180,11 +174,23 @@ public static function build( $request_options = new \WordPress\AiClient\Providers\Http\DTO\RequestOptions(); $request_options->setTimeout( $request_timeout ); $request_options->setConnectTimeout( $connect_timeout ); + $transport_profile['request_options_used'] = true; + $request_metadata['transport'] = $transport_profile; if ( is_object( $model_instance ) && method_exists( $model_instance, 'setRequestOptions' ) ) { $model_instance->setRequestOptions( $request_options ); } } + do_action( + 'datamachine_log', + 'debug', + 'AI transport profile resolved', + array_filter( + $transport_profile, + fn( $v ) => null !== $v + ) + ); + // wp-ai-client refuses to construct a MessagePart from an empty // string. Only pass the prompt text when it is non-empty; otherwise // fall back to instantiating the builder without a prompt and let @@ -239,21 +245,23 @@ public static function build( } } + $dispatch_context = array_filter( + array_merge( + $transport_profile, + array( + 'success' => ! is_wp_error( $result ), + 'error_code' => is_wp_error( $result ) ? $result->get_error_code() : null, + 'error_message' => is_wp_error( $result ) ? $result->get_error_message() : null, + ) + ), + fn( $v ) => null !== $v + ); + do_action( 'datamachine_log', - 'debug', + is_wp_error( $result ) ? 'error' : 'debug', 'AI request dispatched via wp-ai-client', - array_filter( - array( - 'mode' => $mode, - 'job_id' => $payload['job_id'] ?? null, - 'flow_step_id' => $payload['flow_step_id'] ?? null, - 'provider' => $provider, - 'model' => $model, - 'success' => ! is_wp_error( $result ), - ), - fn( $v ) => null !== $v - ) + $dispatch_context ); return $result; @@ -392,9 +400,21 @@ private static function wpAiClientMessageText( $content ): ?string { * @return float Timeout in seconds. */ private static function wpAiClientRequestTimeout( string $mode, string $provider, string $model, array $payload ): float { - $timeout = apply_filters( + $setting_default = PluginSettings::get( + 'wp_ai_client_request_timeout', + PluginSettings::DEFAULT_WP_AI_CLIENT_REQUEST_TIMEOUT + ); + if ( ! is_numeric( $setting_default ) ) { + $setting_default = PluginSettings::DEFAULT_WP_AI_CLIENT_REQUEST_TIMEOUT; + } + + $default_timeout = max( + 0.0, + min( PluginSettings::MAX_WP_AI_CLIENT_REQUEST_TIMEOUT, (float) $setting_default ) + ); + $timeout = apply_filters( 'datamachine_wp_ai_client_request_timeout', - self::DEFAULT_WP_AI_CLIENT_REQUEST_TIMEOUT, + $default_timeout, $mode, $provider, $model, @@ -402,7 +422,7 @@ private static function wpAiClientRequestTimeout( string $mode, string $provider ); if ( ! is_numeric( $timeout ) ) { - return self::DEFAULT_WP_AI_CLIENT_REQUEST_TIMEOUT; + return $default_timeout; } return max( 0.0, (float) $timeout ); @@ -427,8 +447,14 @@ private static function wpAiClientConnectTimeout( string $mode, string $provider $setting_default = PluginSettings::DEFAULT_WP_AI_CLIENT_CONNECT_TIMEOUT; } - $default_timeout = min( max( 0.0, (float) $setting_default ), $request_timeout ); - $timeout = apply_filters( + $default_timeout = min( + max( + 0.0, + min( PluginSettings::MAX_WP_AI_CLIENT_CONNECT_TIMEOUT, (float) $setting_default ) + ), + $request_timeout + ); + $timeout = apply_filters( 'datamachine_wp_ai_client_connect_timeout', $default_timeout, $mode, @@ -445,6 +471,33 @@ private static function wpAiClientConnectTimeout( string $mode, string $provider return max( 0.0, (float) $timeout ); } + /** + * Resolve the transport profile Data Machine applies to a wp-ai-client request. + * + * @param string $mode Execution mode. + * @param string $provider Provider identifier. + * @param string $model Model identifier. + * @param array $payload Step payload. + * @return array Resolved transport profile for logging and inspection. + */ + public static function wpAiClientTransportProfile( string $mode, string $provider, string $model, array $payload ): array { + $request_timeout = self::wpAiClientRequestTimeout( $mode, $provider, $model, $payload ); + $connect_timeout = self::wpAiClientConnectTimeout( $mode, $provider, $model, $payload, $request_timeout ); + + return array( + 'mode' => $mode, + 'provider' => $provider, + 'model' => $model, + 'job_id' => $payload['job_id'] ?? null, + 'flow_step_id' => $payload['flow_step_id'] ?? null, + 'request_timeout' => $request_timeout, + 'connect_timeout' => $connect_timeout, + 'request_options_class_available' => class_exists( '\\WordPress\\AiClient\\Providers\\Http\\DTO\\RequestOptions' ), + 'request_options_used' => false, + 'curl_hook_installed' => false, + ); + } + /** * Assemble a provider request without dispatching it. * diff --git a/inc/Engine/AI/RequestInspector.php b/inc/Engine/AI/RequestInspector.php index 07d831832..f393ece3f 100644 --- a/inc/Engine/AI/RequestInspector.php +++ b/inc/Engine/AI/RequestInspector.php @@ -142,7 +142,7 @@ public function inspectPipelineJob( int $job_id, ?string $flow_step_id = null ): $provider = (string) $mode_model['provider']; $model = (string) $mode_model['model']; - $assembled = RequestBuilder::assemble( + $assembled = RequestBuilder::assemble( $messages, $provider, $model, @@ -150,6 +150,12 @@ public function inspectPipelineJob( int $job_id, ?string $flow_step_id = null ): ToolPolicyResolver::MODE_PIPELINE, $payload ); + $transport_profile = RequestBuilder::wpAiClientTransportProfile( + ToolPolicyResolver::MODE_PIPELINE, + $provider, + $model, + $payload + ); return array_merge( array( @@ -160,6 +166,7 @@ public function inspectPipelineJob( int $job_id, ?string $flow_step_id = null ): 'provider' => $provider, 'model' => $model, 'mode' => ToolPolicyResolver::MODE_PIPELINE, + 'transport' => $transport_profile, ), $this->measure( $assembled, $data_packets, $messages, $packet_projection_context ) ); diff --git a/tests/Unit/Abilities/SettingsAbilitiesTest.php b/tests/Unit/Abilities/SettingsAbilitiesTest.php index d5010bc12..bd377a831 100644 --- a/tests/Unit/Abilities/SettingsAbilitiesTest.php +++ b/tests/Unit/Abilities/SettingsAbilitiesTest.php @@ -99,10 +99,28 @@ public function test_get_settings_contains_expected_keys(): void { $this->assertArrayHasKey( 'default_provider', $settings ); $this->assertArrayHasKey( 'default_model', $settings ); $this->assertArrayHasKey( 'max_turns', $settings ); + $this->assertArrayHasKey( 'wp_ai_client_connect_timeout', $settings ); + $this->assertArrayHasKey( 'wp_ai_client_request_timeout', $settings ); $this->assertArrayHasKey( 'disabled_tools', $settings ); $this->assertArrayHasKey( 'ai_provider_keys', $settings ); } + public function test_update_settings_clamps_wp_ai_client_timeouts(): void { + $result = $this->settings_abilities->executeUpdateSettings( + array( + 'wp_ai_client_connect_timeout' => 999, + 'wp_ai_client_request_timeout' => 9999, + ) + ); + + $this->assertTrue( $result['success'] ); + $this->assertArrayNotHasKey( 'unhandled_keys', $result ); + + $updated_settings = get_option( 'datamachine_settings', array() ); + $this->assertSame( 300.0, $updated_settings['wp_ai_client_connect_timeout'] ); + $this->assertSame( 900.0, $updated_settings['wp_ai_client_request_timeout'] ); + } + public function test_update_settings_updates_boolean_setting(): void { $result = $this->settings_abilities->executeUpdateSettings( array( 'cleanup_job_data_on_failure' => false ) diff --git a/tests/wp-ai-client-request-timeout-smoke.php b/tests/wp-ai-client-request-timeout-smoke.php index 5d3468c0e..cd0d2bc79 100644 --- a/tests/wp-ai-client-request-timeout-smoke.php +++ b/tests/wp-ai-client-request-timeout-smoke.php @@ -9,7 +9,9 @@ declare(strict_types=1); -$GLOBALS['datamachine_timeout_test_filters'] = array(); +$GLOBALS['datamachine_timeout_test_filters'] = array(); +$GLOBALS['datamachine_timeout_test_actions'] = array(); +$GLOBALS['datamachine_timeout_test_settings'] = array(); if ( ! function_exists( 'add_filter' ) ) { function add_filter( string $tag, callable $callback, int $priority = 10, int $accepted_args = 1 ): void { @@ -53,6 +55,7 @@ function apply_filters( string $tag, $value, ...$args ) { if ( ! function_exists( 'do_action' ) ) { function do_action( string $tag, ...$args ): void { $GLOBALS['datamachine_timeout_test_last_action'] = array( $tag, $args ); + $GLOBALS['datamachine_timeout_test_actions'][] = array( $tag, $args ); } } @@ -76,7 +79,9 @@ function sanitize_key( string $key ): string { if ( ! function_exists( 'get_option' ) ) { function get_option( string $option, $default = false ) { - unset( $option ); + if ( 'datamachine_settings' === $option ) { + return $GLOBALS['datamachine_timeout_test_settings']; + } return $default; } } @@ -204,6 +209,10 @@ function timeout_smoke_filter_count( string $tag ): int { $timeout_context = null; $connect_timeout_context = null; $GLOBALS['datamachine_test_wp_ai_client_model_with_request_options'] = true; +$GLOBALS['datamachine_timeout_test_settings'] = array( + 'wp_ai_client_connect_timeout' => 25.0, + 'wp_ai_client_request_timeout' => 180.0, +); add_filter( 'datamachine_wp_ai_client_request_timeout', @@ -225,7 +234,8 @@ function ( float $timeout, string $mode, string $provider, string $model, array 6 ); -$result = RequestBuilder::build( +$request_metadata = null; +$result = RequestBuilder::build( array( array( 'role' => 'user', @@ -244,12 +254,13 @@ function ( float $timeout, string $mode, string $provider, string $model, array 'gpt-smoke', array(), 'pipeline', - array( 'job_id' => 1695 ) + array( 'job_id' => 1695, 'flow_step_id' => 'ai-step-1' ), + $request_metadata ); assert_timeout_smoke( ! is_wp_error( $result ), 'RequestBuilder dispatch succeeds with wp-ai-client test double' ); assert_timeout_smoke( 240.0 === ( TimeoutPromptBuilderDouble::$captured_request['timeout'] ?? null ), 'Data Machine applies scoped wp-ai-client request timeout' ); -assert_timeout_smoke( 300.0 === ( $timeout_context['timeout'] ?? null ), 'Data Machine timeout filter receives product default' ); +assert_timeout_smoke( 180.0 === ( $timeout_context['timeout'] ?? null ), 'Data Machine timeout filter receives configured request timeout default' ); assert_timeout_smoke( 'pipeline' === ( $timeout_context['mode'] ?? null ), 'Data Machine timeout filter receives execution mode' ); assert_timeout_smoke( 'openai' === ( $timeout_context['provider'] ?? null ), 'Data Machine timeout filter receives provider' ); assert_timeout_smoke( 'gpt-smoke' === ( $timeout_context['model'] ?? null ), 'Data Machine timeout filter receives model' ); @@ -265,7 +276,7 @@ function ( float $timeout, string $mode, string $provider, string $model, array assert_timeout_smoke( 240.0 === $captured_builder_request_options?->getTimeout(), 'Data Machine sets PromptBuilder RequestOptions timeout from scoped request timeout' ); assert_timeout_smoke( 120.0 === $captured_builder_request_options?->getConnectTimeout(), 'Data Machine sets PromptBuilder RequestOptions connect timeout from scoped connect timeout' ); -assert_timeout_smoke( 30.0 === ( $connect_timeout_context['timeout'] ?? null ), 'Data Machine connect timeout filter receives product default' ); +assert_timeout_smoke( 25.0 === ( $connect_timeout_context['timeout'] ?? null ), 'Data Machine connect timeout filter receives configured connect timeout default' ); assert_timeout_smoke( 240.0 === ( $connect_timeout_context['request_timeout'] ?? null ), 'Data Machine connect timeout filter receives resolved request timeout' ); assert_timeout_smoke( 'pipeline' === ( $connect_timeout_context['mode'] ?? null ), 'Data Machine connect timeout filter receives execution mode' ); assert_timeout_smoke( 'openai' === ( $connect_timeout_context['provider'] ?? null ), 'Data Machine connect timeout filter receives provider' ); @@ -278,6 +289,25 @@ function ( float $timeout, string $mode, string $provider, string $model, array assert_timeout_smoke( 'continue' === $captured_prompt, 'Data Machine passes latest user message as wp-ai-client prompt' ); assert_timeout_smoke( 1 === ( TimeoutPromptBuilderDouble::$captured_request['curl_filter_count'] ?? null ), 'Data Machine scopes cURL low-speed settings during wp-ai-client dispatch' ); +$transport = $request_metadata['transport'] ?? array(); +assert_timeout_smoke( 240.0 === ( $transport['request_timeout'] ?? null ), 'Request metadata includes resolved request timeout' ); +assert_timeout_smoke( 120.0 === ( $transport['connect_timeout'] ?? null ), 'Request metadata includes resolved connect timeout' ); +assert_timeout_smoke( true === ( $transport['request_options_class_available'] ?? null ), 'Request metadata includes RequestOptions availability' ); +assert_timeout_smoke( true === ( $transport['request_options_used'] ?? null ), 'Request metadata includes RequestOptions usage' ); +assert_timeout_smoke( true === ( $transport['curl_hook_installed'] ?? null ), 'Request metadata includes cURL hook installation state' ); +assert_timeout_smoke( 'ai-step-1' === ( $transport['flow_step_id'] ?? null ), 'Request metadata includes flow step id' ); + +$transport_logs = array_values( + array_filter( + $GLOBALS['datamachine_timeout_test_actions'], + static fn( $action ) => 'datamachine_log' === ( $action[0] ?? null ) && 'AI transport profile resolved' === ( $action[1][1] ?? null ) + ) +); +assert_timeout_smoke( 1 === count( $transport_logs ), 'Data Machine logs resolved AI transport profile before dispatch' ); +$transport_log_context = $transport_logs[0][1][2] ?? array(); +assert_timeout_smoke( 240.0 === ( $transport_log_context['request_timeout'] ?? null ), 'Transport log includes resolved request timeout' ); +assert_timeout_smoke( 120.0 === ( $transport_log_context['connect_timeout'] ?? null ), 'Transport log includes resolved connect timeout' ); + $request_builder_source = file_get_contents( __DIR__ . '/../inc/Engine/AI/RequestBuilder.php' ); assert_timeout_smoke( is_string( $request_builder_source ) && str_contains( $request_builder_source, 'CURLOPT_CONNECTTIMEOUT' ), 'Data Machine scopes cURL connect timeout during wp-ai-client dispatch' ); assert_timeout_smoke( is_string( $request_builder_source ) && str_contains( $request_builder_source, 'use ( $request_timeout, $connect_timeout )' ), 'Data Machine cURL timeout hook receives request and connect timeouts' );