From d015ed1b0c0f647a9fd952526bbd4a7e8b60f460 Mon Sep 17 00:00:00 2001 From: Mike Fortman Date: Tue, 30 Sep 2025 14:34:55 -0500 Subject: [PATCH] add container utils (#151) * add container utils * added localhost url to settings * added localhost_url as a constant * added localhost_url to get settings query * make ollama onboarding have localhost url by default * make endpoint be changed in models service and in onboarding backend instead of onboarding screen * fixed embedding dimensions to get stripped model * make config come as localhost but global variable be set as the transformed endpoint * remove setting ollama url since it comes from the global variable * use localhost again on ollama --------- Co-authored-by: Lucas Oliveira --- .../app/api/queries/useGetSettingsQuery.ts | 1 + .../components/ollama-onboarding.tsx | 2 +- src/api/settings.py | 6 +- src/config/settings.py | 3 + src/services/flows_service.py | 5 - src/services/models_service.py | 3 +- src/utils/container_utils.py | 138 ++++++++++++++++++ src/utils/embeddings.py | 2 + 8 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 src/utils/container_utils.py diff --git a/frontend/src/app/api/queries/useGetSettingsQuery.ts b/frontend/src/app/api/queries/useGetSettingsQuery.ts index d2d5a15d..0f090299 100644 --- a/frontend/src/app/api/queries/useGetSettingsQuery.ts +++ b/frontend/src/app/api/queries/useGetSettingsQuery.ts @@ -37,6 +37,7 @@ export interface Settings { separator?: string; embeddingModel?: string; }; + localhost_url?: string; } export const useGetSettingsQuery = ( diff --git a/frontend/src/app/onboarding/components/ollama-onboarding.tsx b/frontend/src/app/onboarding/components/ollama-onboarding.tsx index 0aaf991f..b40e6714 100644 --- a/frontend/src/app/onboarding/components/ollama-onboarding.tsx +++ b/frontend/src/app/onboarding/components/ollama-onboarding.tsx @@ -19,7 +19,7 @@ export function OllamaOnboarding({ sampleDataset: boolean; setSampleDataset: (dataset: boolean) => void; }) { - const [endpoint, setEndpoint] = useState("http://localhost:11434"); + const [endpoint, setEndpoint] = useState(`http://localhost:11434`); const [showConnecting, setShowConnecting] = useState(false); const debouncedEndpoint = useDebouncedValue(endpoint, 500); diff --git a/src/api/settings.py b/src/api/settings.py index a99cce61..a3fdeee3 100644 --- a/src/api/settings.py +++ b/src/api/settings.py @@ -1,6 +1,7 @@ import json import platform from starlette.responses import JSONResponse +from utils.container_utils import transform_localhost_url from utils.logging_config import get_logger from config.settings import ( LANGFLOW_URL, @@ -8,6 +9,7 @@ from config.settings import ( LANGFLOW_INGEST_FLOW_ID, LANGFLOW_PUBLIC_URL, DOCLING_COMPONENT_ID, + LOCALHOST_URL, clients, get_openrag_config, config_manager, @@ -74,6 +76,7 @@ async def get_settings(request, session_manager): "llm_model": agent_config.llm_model, "system_prompt": agent_config.system_prompt, }, + "localhost_url": LOCALHOST_URL, } # Only expose edit URLs when a public URL is configured @@ -570,7 +573,8 @@ async def onboarding(request, flows_service): # Set base URL for Ollama provider if provider == "ollama" and "endpoint" in body: - endpoint = body["endpoint"] + endpoint = transform_localhost_url(body["endpoint"]) + await clients._create_langflow_global_variable( "OLLAMA_BASE_URL", endpoint, modify=True ) diff --git a/src/config/settings.py b/src/config/settings.py index 517bf2df..fc6a9ec9 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -9,6 +9,7 @@ from openai import AsyncOpenAI from opensearchpy import AsyncOpenSearch from opensearchpy._async.http_aiohttp import AIOHttpConnection +from utils.container_utils import get_container_host from utils.document_processing import create_document_converter from utils.logging_config import get_logger @@ -575,6 +576,8 @@ OLLAMA_LLM_TEXT_COMPONENT_ID = os.getenv( # Docling component ID for ingest flow DOCLING_COMPONENT_ID = os.getenv("DOCLING_COMPONENT_ID", "DoclingRemote-78KoX") +LOCALHOST_URL = get_container_host() or "localhost" + # Global clients instance clients = AppClients() diff --git a/src/services/flows_service.py b/src/services/flows_service.py index 7397cf6b..164fc122 100644 --- a/src/services/flows_service.py +++ b/src/services/flows_service.py @@ -836,10 +836,5 @@ class FlowsService: template["url"]["value"] = endpoint template["url"]["options"] = [endpoint] updated = True - elif provider == "ollama" and "base_url" in template: - # Ollama uses "base_url" field - template["base_url"]["value"] = endpoint - # Note: base_url is typically a MessageTextInput, not dropdown, so no options field - updated = True return updated diff --git a/src/services/models_service.py b/src/services/models_service.py index 35a54895..a90a74f4 100644 --- a/src/services/models_service.py +++ b/src/services/models_service.py @@ -1,5 +1,6 @@ import httpx from typing import Dict, List +from utils.container_utils import transform_localhost_url from utils.logging_config import get_logger logger = get_logger(__name__) @@ -95,7 +96,7 @@ class ModelsService: """Fetch available models from Ollama API with tool calling capabilities for language models""" try: # Use provided endpoint or default - ollama_url = endpoint + ollama_url = transform_localhost_url(endpoint) # API endpoints tags_url = f"{ollama_url}/api/tags" diff --git a/src/utils/container_utils.py b/src/utils/container_utils.py new file mode 100644 index 00000000..a18d9f1c --- /dev/null +++ b/src/utils/container_utils.py @@ -0,0 +1,138 @@ +"""Utilities for detecting and working with container environments.""" + +import os +from pathlib import Path + + +def detect_container_environment() -> str | None: + """Detect if running in a container and return the appropriate container type. + + Returns: + 'docker' if running in Docker, 'podman' if running in Podman, None otherwise. + """ + # Check for .dockerenv file (Docker) + if Path("/.dockerenv").exists(): + return "docker" + + # Check cgroup for container indicators + try: + with Path("/proc/self/cgroup").open() as f: + content = f.read() + if "docker" in content: + return "docker" + if "podman" in content: + return "podman" + except (FileNotFoundError, PermissionError): + pass + + # Check environment variables (lowercase 'container' is the standard for Podman) + if os.getenv("container") == "podman": # noqa: SIM112 + return "podman" + + return None + + +def get_container_host() -> str | None: + """Get the hostname to access host services from within a container. + + Tries multiple methods to find the correct hostname: + 1. host.containers.internal (Podman) or host.docker.internal (Docker) + 2. Gateway IP from routing table (fallback for Linux) + + Returns: + The hostname or IP to use, or None if not in a container. + """ + import socket + + # Check if we're in a container first + container_type = detect_container_environment() + if not container_type: + return None + + # Try container-specific hostnames first based on detected type + if container_type == "podman": + # Podman: try host.containers.internal first + try: + socket.getaddrinfo("host.containers.internal", None) + except socket.gaierror: + pass + else: + return "host.containers.internal" + + # Fallback to host.docker.internal (for Podman Desktop on macOS) + try: + socket.getaddrinfo("host.docker.internal", None) + except socket.gaierror: + pass + else: + return "host.docker.internal" + else: + # Docker: try host.docker.internal first + try: + socket.getaddrinfo("host.docker.internal", None) + except socket.gaierror: + pass + else: + return "host.docker.internal" + + # Fallback to host.containers.internal (unlikely but possible) + try: + socket.getaddrinfo("host.containers.internal", None) + except socket.gaierror: + pass + else: + return "host.containers.internal" + + # Fallback: try to get gateway IP from routing table (Linux containers) + try: + with Path("/proc/net/route").open() as f: + for line in f: + fields = line.strip().split() + min_field_count = 3 # Minimum fields needed: interface, destination, gateway + if len(fields) >= min_field_count and fields[1] == "00000000": # Default route + # Gateway is in hex format (little-endian) + gateway_hex = fields[2] + # Convert hex to IP address + # The hex is in little-endian format, so we read it backwards in pairs + octets = [gateway_hex[i : i + 2] for i in range(0, 8, 2)] + return ".".join(str(int(octet, 16)) for octet in reversed(octets)) + except (FileNotFoundError, PermissionError, IndexError, ValueError): + pass + + return None + + +def transform_localhost_url(url: str) -> str: + """Transform localhost URLs to container-accessible hosts when running in a container. + + Automatically detects if running inside a container and finds the appropriate host + address to replace localhost/127.0.0.1. Tries in order: + - host.docker.internal (if resolvable) + - host.containers.internal (if resolvable) + - Gateway IP from routing table (fallback) + + Args: + url: The original URL + + Returns: + Transformed URL with container-accessible host if applicable, otherwise the original URL. + + Example: + >>> transform_localhost_url("http://localhost:5001") + # Returns "http://host.docker.internal:5001" if running in Docker and hostname resolves + # Returns "http://172.17.0.1:5001" if running in Docker on Linux (gateway IP fallback) + # Returns "http://localhost:5001" if not in a container + """ + container_host = get_container_host() + + if not container_host: + return url + + # Replace localhost and 127.0.0.1 with the container host + localhost_patterns = ["localhost", "127.0.0.1"] + + for pattern in localhost_patterns: + if pattern in url: + return url.replace(pattern, container_host) + + return url diff --git a/src/utils/embeddings.py b/src/utils/embeddings.py index f3c902e7..b0ec035f 100644 --- a/src/utils/embeddings.py +++ b/src/utils/embeddings.py @@ -10,6 +10,8 @@ def get_embedding_dimensions(model_name: str) -> int: # Check all model dictionaries all_models = {**OPENAI_EMBEDDING_DIMENSIONS, **OLLAMA_EMBEDDING_DIMENSIONS, **WATSONX_EMBEDDING_DIMENSIONS} + model_name = model_name.lower().strip().split(":")[0] + if model_name in all_models: dimensions = all_models[model_name] logger.info(f"Found dimensions for model '{model_name}': {dimensions}")