From 80fdd9680d046fc3557d6300c2293ed0566ebe0d Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 29 Oct 2025 22:38:31 -0400 Subject: [PATCH 1/3] make openai optional in tui and lazy client creation in backend --- src/config/settings.py | 54 +++++++++++++++++++++++++++++++-- src/connectors/service.py | 2 +- src/main.py | 2 +- src/tui/managers/env_manager.py | 11 ++++--- src/tui/screens/config.py | 6 +++- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index 53025937..9d7d13c5 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -279,7 +279,7 @@ class AppClients: self.opensearch = None self.langflow_client = None self.langflow_http_client = None - self.patched_async_client = None + self._patched_async_client = None # Private attribute self.converter = None async def initialize(self): @@ -318,8 +318,21 @@ class AppClients: "No Langflow client initialized yet, will attempt later on first use" ) - # Initialize patched OpenAI client - self.patched_async_client = patch_openai_with_mcp(AsyncOpenAI()) + # Initialize patched OpenAI client if API key is available + # This allows the app to start even if OPENAI_API_KEY is not set yet + # (e.g., when it will be provided during onboarding) + # The property will handle lazy initialization if needed later + if self._patched_async_client is None: + try: + openai_key = os.getenv("OPENAI_API_KEY") + if openai_key: + self._patched_async_client = patch_openai_with_mcp(AsyncOpenAI()) + logger.info("OpenAI client initialized with API key from environment") + else: + logger.info("OpenAI API key not found in environment - will be initialized on first use if needed") + except Exception as e: + logger.warning("Failed to initialize OpenAI client", error=str(e)) + self._patched_async_client = None # Initialize document converter self.converter = create_document_converter(ocr_engine=DOCLING_OCR_ENGINE) @@ -350,6 +363,41 @@ class AppClients: self.langflow_client = None return self.langflow_client + @property + def patched_async_client(self): + """ + Property that ensures OpenAI client is initialized on first access. + This allows lazy initialization so the app can start without an API key. + """ + if self._patched_async_client is not None: + return self._patched_async_client + + # Try to initialize the client on-demand + # First check if OPENAI_API_KEY is in environment + openai_key = os.getenv("OPENAI_API_KEY") + + if not openai_key: + # Try to get from config (in case it was set during onboarding) + try: + config = get_openrag_config() + if config and config.provider and config.provider.api_key: + openai_key = config.provider.api_key + # Set it in environment so AsyncOpenAI can pick it up + os.environ["OPENAI_API_KEY"] = openai_key + logger.info("Loaded OpenAI API key from config file") + except Exception as e: + logger.debug("Could not load OpenAI key from config", error=str(e)) + + # Try to initialize the client - AsyncOpenAI() will read from environment + try: + self._patched_async_client = patch_openai_with_mcp(AsyncOpenAI()) + logger.info("OpenAI client initialized on-demand") + except Exception as e: + logger.error("Failed to initialize OpenAI client on-demand", error=str(e)) + raise ValueError(f"Failed to initialize OpenAI client: {str(e)}. Please complete onboarding or set OPENAI_API_KEY environment variable.") + + return self._patched_async_client + async def langflow_request(self, method: str, endpoint: str, **kwargs): """Central method for all Langflow API requests""" api_key = await generate_langflow_api_key() diff --git a/src/connectors/service.py b/src/connectors/service.py index 96daaf77..96396f99 100644 --- a/src/connectors/service.py +++ b/src/connectors/service.py @@ -21,7 +21,7 @@ class ConnectorService: task_service=None, session_manager=None, ): - self.openai_client = patched_async_client + self.clients = patched_async_client # Store the clients object to access the property self.process_pool = process_pool self.embed_model = embed_model self.index_name = index_name diff --git a/src/main.py b/src/main.py index 0fed1ee8..cb7de4df 100644 --- a/src/main.py +++ b/src/main.py @@ -470,7 +470,7 @@ async def initialize_services(): session_manager=session_manager, ) openrag_connector_service = ConnectorService( - patched_async_client=clients.patched_async_client, + patched_async_client=clients, # Pass the clients object itself process_pool=process_pool, embed_model=get_embedding_model(), index_name=INDEX_NAME, diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index 8ee86627..751283dc 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -170,8 +170,9 @@ class EnvManager: """ self.config.validation_errors.clear() - # Always validate OpenAI API key - if not validate_openai_api_key(self.config.openai_api_key): + # OpenAI API key is now optional (can be provided during onboarding) + # Only validate format if a key is provided + if self.config.openai_api_key and not validate_openai_api_key(self.config.openai_api_key): self.config.validation_errors["openai_api_key"] = ( "Invalid OpenAI API key format (should start with sk-)" ) @@ -268,7 +269,9 @@ class EnvManager: f.write(f"LANGFLOW_URL_INGEST_FLOW_ID={self._quote_env_value(self.config.langflow_url_ingest_flow_id)}\n") f.write(f"NUDGES_FLOW_ID={self._quote_env_value(self.config.nudges_flow_id)}\n") f.write(f"OPENSEARCH_PASSWORD={self._quote_env_value(self.config.opensearch_password)}\n") - f.write(f"OPENAI_API_KEY={self._quote_env_value(self.config.openai_api_key)}\n") + # Only write OpenAI API key if provided (can be set during onboarding instead) + if self.config.openai_api_key: + f.write(f"OPENAI_API_KEY={self._quote_env_value(self.config.openai_api_key)}\n") f.write( f"OPENRAG_DOCUMENTS_PATHS={self._quote_env_value(self.config.openrag_documents_paths)}\n" ) @@ -345,7 +348,7 @@ class EnvManager: def get_no_auth_setup_fields(self) -> List[tuple[str, str, str, bool]]: """Get fields required for no-auth setup mode. Returns (field_name, display_name, placeholder, can_generate).""" return [ - ("openai_api_key", "OpenAI API Key", "sk-...", False), + ("openai_api_key", "OpenAI API Key", "sk-... or leave empty", False), ( "opensearch_password", "OpenSearch Password", diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index a9ef334d..7338696c 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -203,12 +203,16 @@ class ConfigScreen(Screen): yield Static(" ") # OpenAI API Key - yield Label("OpenAI API Key *") + yield Label("OpenAI API Key") # Where to create OpenAI keys (helper above the box) yield Static( Text("Get a key: https://platform.openai.com/api-keys", style="dim"), classes="helper-text", ) + yield Static( + Text("Can also be provided during onboarding", style="dim italic"), + classes="helper-text", + ) current_value = getattr(self.env_manager.config, "openai_api_key", "") with Horizontal(id="openai-key-row"): input_widget = Input( From 653ed344bebc5e09296877777c6d04ddd8c13a4c Mon Sep 17 00:00:00 2001 From: phact Date: Fri, 31 Oct 2025 15:50:30 -0400 Subject: [PATCH 2/3] strip quotes in Makefile --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5c963298..6f0c821c 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,12 @@ # Provides easy commands for development workflow # Load variables from .env if present so `make` commands pick them up +# Strip quotes from values to avoid issues with tools that don't handle them like python-dotenv does ifneq (,$(wildcard .env)) include .env - # Export all simple KEY=VALUE pairs to the environment for child processes export $(shell sed -n 's/^\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' .env) + # Strip single quotes from all exported variables + $(foreach var,$(shell sed -n 's/^\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' .env),$(eval $(var):=$(shell echo $($(var)) | sed "s/^'//;s/'$$//"))) endif .PHONY: help dev dev-cpu dev-local infra stop clean build logs shell-backend shell-frontend install \ From 563efd957fca2020587847cc52ed4d8f3e94b1f6 Mon Sep 17 00:00:00 2001 From: phact Date: Fri, 31 Oct 2025 15:52:10 -0400 Subject: [PATCH 3/3] lazy client initialization + client cleanup + http2 probe and fallback --- src/config/settings.py | 163 +++++++++++++++++++++++++++++++++-------- src/main.py | 2 + 2 files changed, 133 insertions(+), 32 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index 9d7d13c5..69827892 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -280,6 +280,7 @@ class AppClients: self.langflow_client = None self.langflow_http_client = None self._patched_async_client = None # Private attribute + self._client_init_lock = __import__('threading').Lock() # Lock for thread-safe initialization self.converter = None async def initialize(self): @@ -321,18 +322,12 @@ class AppClients: # Initialize patched OpenAI client if API key is available # This allows the app to start even if OPENAI_API_KEY is not set yet # (e.g., when it will be provided during onboarding) - # The property will handle lazy initialization if needed later - if self._patched_async_client is None: - try: - openai_key = os.getenv("OPENAI_API_KEY") - if openai_key: - self._patched_async_client = patch_openai_with_mcp(AsyncOpenAI()) - logger.info("OpenAI client initialized with API key from environment") - else: - logger.info("OpenAI API key not found in environment - will be initialized on first use if needed") - except Exception as e: - logger.warning("Failed to initialize OpenAI client", error=str(e)) - self._patched_async_client = None + # The property will handle lazy initialization with probe when first accessed + openai_key = os.getenv("OPENAI_API_KEY") + if openai_key: + logger.info("OpenAI API key found in environment - will be initialized lazily on first use with HTTP/2 probe") + else: + logger.info("OpenAI API key not found in environment - will be initialized on first use if needed") # Initialize document converter self.converter = create_document_converter(ocr_engine=DOCLING_OCR_ENGINE) @@ -368,35 +363,139 @@ class AppClients: """ Property that ensures OpenAI client is initialized on first access. This allows lazy initialization so the app can start without an API key. + + Note: The client is a long-lived singleton that should be closed via cleanup(). + Thread-safe via lock to prevent concurrent initialization attempts. """ + # Quick check without lock if self._patched_async_client is not None: return self._patched_async_client - # Try to initialize the client on-demand - # First check if OPENAI_API_KEY is in environment - openai_key = os.getenv("OPENAI_API_KEY") + # Use lock to ensure only one thread initializes + with self._client_init_lock: + # Double-check after acquiring lock + if self._patched_async_client is not None: + return self._patched_async_client + + # Try to initialize the client on-demand + # First check if OPENAI_API_KEY is in environment + openai_key = os.getenv("OPENAI_API_KEY") + + if not openai_key: + # Try to get from config (in case it was set during onboarding) + try: + config = get_openrag_config() + if config and config.provider and config.provider.api_key: + openai_key = config.provider.api_key + # Set it in environment so AsyncOpenAI can pick it up + os.environ["OPENAI_API_KEY"] = openai_key + logger.info("Loaded OpenAI API key from config file") + except Exception as e: + logger.debug("Could not load OpenAI key from config", error=str(e)) + + # Try to initialize the client - AsyncOpenAI() will read from environment + # We'll try HTTP/2 first with a probe, then fall back to HTTP/1.1 if it times out + import asyncio + import concurrent.futures + import threading + + async def probe_and_initialize(): + # Try HTTP/2 first (default) + client_http2 = patch_openai_with_mcp(AsyncOpenAI()) + logger.info("Probing OpenAI client with HTTP/2...") + + try: + # Probe with a small embedding and short timeout + await asyncio.wait_for( + client_http2.embeddings.create( + model='text-embedding-3-small', + input=['test'] + ), + timeout=5.0 + ) + logger.info("OpenAI client initialized with HTTP/2 (probe successful)") + return client_http2 + except (asyncio.TimeoutError, Exception) as probe_error: + logger.warning("HTTP/2 probe failed, falling back to HTTP/1.1", error=str(probe_error)) + # Close the HTTP/2 client + try: + await client_http2.close() + except Exception: + pass + + # Fall back to HTTP/1.1 with explicit timeout settings + http_client = httpx.AsyncClient( + http2=False, + timeout=httpx.Timeout(60.0, connect=10.0) + ) + client_http1 = patch_openai_with_mcp( + AsyncOpenAI(http_client=http_client) + ) + logger.info("OpenAI client initialized with HTTP/1.1 (fallback)") + return client_http1 + + def run_probe_in_thread(): + """Run the async probe in a new thread with its own event loop""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(probe_and_initialize()) + finally: + loop.close() - if not openai_key: - # Try to get from config (in case it was set during onboarding) try: - config = get_openrag_config() - if config and config.provider and config.provider.api_key: - openai_key = config.provider.api_key - # Set it in environment so AsyncOpenAI can pick it up - os.environ["OPENAI_API_KEY"] = openai_key - logger.info("Loaded OpenAI API key from config file") + # Run the probe in a separate thread with its own event loop + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(run_probe_in_thread) + self._patched_async_client = future.result(timeout=15) + logger.info("Successfully initialized OpenAI client") except Exception as e: - logger.debug("Could not load OpenAI key from config", error=str(e)) + logger.error(f"Failed to initialize OpenAI client: {e.__class__.__name__}: {str(e)}") + raise ValueError(f"Failed to initialize OpenAI client: {str(e)}. Please complete onboarding or set OPENAI_API_KEY environment variable.") - # Try to initialize the client - AsyncOpenAI() will read from environment - try: - self._patched_async_client = patch_openai_with_mcp(AsyncOpenAI()) - logger.info("OpenAI client initialized on-demand") - except Exception as e: - logger.error("Failed to initialize OpenAI client on-demand", error=str(e)) - raise ValueError(f"Failed to initialize OpenAI client: {str(e)}. Please complete onboarding or set OPENAI_API_KEY environment variable.") + return self._patched_async_client - return self._patched_async_client + async def cleanup(self): + """Cleanup resources - should be called on application shutdown""" + # Close AsyncOpenAI client if it was created + if self._patched_async_client is not None: + try: + await self._patched_async_client.close() + logger.info("Closed AsyncOpenAI client") + except Exception as e: + logger.error("Failed to close AsyncOpenAI client", error=str(e)) + finally: + self._patched_async_client = None + + # Close Langflow HTTP client if it exists + if self.langflow_http_client is not None: + try: + await self.langflow_http_client.aclose() + logger.info("Closed Langflow HTTP client") + except Exception as e: + logger.error("Failed to close Langflow HTTP client", error=str(e)) + finally: + self.langflow_http_client = None + + # Close OpenSearch client if it exists + if self.opensearch is not None: + try: + await self.opensearch.close() + logger.info("Closed OpenSearch client") + except Exception as e: + logger.error("Failed to close OpenSearch client", error=str(e)) + finally: + self.opensearch = None + + # Close Langflow client if it exists (also an AsyncOpenAI client) + if self.langflow_client is not None: + try: + await self.langflow_client.close() + logger.info("Closed Langflow client") + except Exception as e: + logger.error("Failed to close Langflow client", error=str(e)) + finally: + self.langflow_client = None async def langflow_request(self, method: str, endpoint: str, **kwargs): """Central method for all Langflow API requests""" diff --git a/src/main.py b/src/main.py index cb7de4df..08d2de33 100644 --- a/src/main.py +++ b/src/main.py @@ -1108,6 +1108,8 @@ async def create_app(): @app.on_event("shutdown") async def shutdown_event(): await cleanup_subscriptions_proper(services) + # Cleanup async clients + await clients.cleanup() return app