diff --git a/omlx/cli.py b/omlx/cli.py
index 10d2460f1..10cd0e4ab 100644
--- a/omlx/cli.py
+++ b/omlx/cli.py
@@ -168,19 +168,32 @@ def serve_command(args):
# Bind the socket before importing/initializing the server. Uvicorn's
# normal startup runs ASGI lifespan before binding host/port, which means
# pinned models can be preloaded before a port conflict is detected.
- print(f"Binding server at http://{settings.server.host}:{settings.server.port}")
+ bind_hosts = [h.strip() for h in settings.server.host.split(",") if h.strip()]
+ for h in bind_hosts:
+ print(f"Binding server at http://{h}:{settings.server.port}")
# uvicorn does not support "trace" — map to "debug" for its internal logging
uvicorn_level = "debug" if settings.server.log_level == "trace" else settings.server.log_level
# Only show access logs at trace level
show_access_log = settings.server.log_level == "trace"
uvicorn_config = uvicorn.Config(
"omlx.server:app",
- host=settings.server.host,
+ host=bind_hosts[0],
port=settings.server.port,
log_level=uvicorn_level,
access_log=show_access_log,
)
- serve_socket = uvicorn_config.bind_socket()
+ # Bind a socket per host so an occupied port fails fast before model preload.
+ # uvicorn.Server.run(sockets=[...]) accepts a list and listens on all of them.
+ serve_sockets = [uvicorn_config.bind_socket()]
+ for h in bind_hosts[1:]:
+ extra_cfg = uvicorn.Config(
+ "omlx.server:app",
+ host=h,
+ port=settings.server.port,
+ log_level=uvicorn_level,
+ access_log=show_access_log,
+ )
+ serve_sockets.append(extra_cfg.bind_socket())
try:
# Import server and config after the port is known to be available.
@@ -276,15 +289,17 @@ def serve_command(args):
global_settings=settings,
)
- print(f"Starting server at http://{settings.server.host}:{settings.server.port}")
+ for h in bind_hosts:
+ print(f"Starting server at http://{h}:{settings.server.port}")
try:
- uvicorn.Server(uvicorn_config).run(sockets=[serve_socket])
+ uvicorn.Server(uvicorn_config).run(sockets=serve_sockets)
except KeyboardInterrupt:
pass
finally:
# Uvicorn closes sockets during normal shutdown; this covers failures
# after bind succeeds but before the server takes ownership.
- serve_socket.close()
+ for sock in serve_sockets:
+ sock.close()
def launch_command(args, extra_args: list[str] | None = None):
@@ -321,10 +336,11 @@ def _optional_str(value) -> str | None:
host = args.host or settings.server.host
port = args.port or settings.server.port
- # 0.0.0.0 is a valid bind address but not a valid connect address.
- # Fall back to localhost so launch can reach the server regardless
- # of which interface it was bound to.
- connect_host = host if host and host != "0.0.0.0" else "127.0.0.1"
+ # host may be a comma-separated list of bind addresses; pick the first one
+ # for connecting. Wildcard addresses (0.0.0.0, ::) are valid bind targets
+ # but not connectable — fall back to localhost in that case.
+ first_bind = [h.strip() for h in host.split(",") if h.strip()][0] if host else ""
+ connect_host = first_bind if first_bind not in ("", "0.0.0.0", "::") else "127.0.0.1"
# Check if oMLX server is running
base_url = f"http://{connect_host}:{port}"
diff --git a/omlx/settings.py b/omlx/settings.py
index 6181e732f..6b595c222 100644
--- a/omlx/settings.py
+++ b/omlx/settings.py
@@ -116,8 +116,9 @@ def to_dict(self) -> dict[str, Any]:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ServerSettings:
"""Create from dictionary."""
+ _host = data.get("host", data.get("bind_address", "127.0.0.1"))
return cls(
- host=data.get("host", data.get("bind_address", "127.0.0.1")),
+ host=", ".join(_host) if isinstance(_host, list) else str(_host),
port=data.get("port", 8000),
log_level=data.get("log_level", "info"),
cors_origins=data.get("cors_origins", ["*"]),
diff --git a/omlx/utils/network.py b/omlx/utils/network.py
index 272cdc64f..3412617db 100644
--- a/omlx/utils/network.py
+++ b/omlx/utils/network.py
@@ -64,6 +64,25 @@ def is_valid_alias(value: str) -> bool:
return is_valid_ip(value)
+def is_valid_bind_host(value: str) -> bool:
+ """Return True if ``value`` is a valid host to bind a server socket to.
+
+ Accepts any parseable IP address (including ``0.0.0.0`` and ``::``) and
+ valid DNS hostnames. Unlike :func:`is_valid_alias`, unspecified addresses
+ are allowed because they are legitimate bind targets.
+ """
+ if not isinstance(value, str):
+ return False
+ value = value.strip()
+ if not value:
+ return False
+ try:
+ ipaddress.ip_address(value)
+ return True
+ except ValueError:
+ return is_valid_hostname(value)
+
+
def _local_ipv4_addresses() -> list[str]:
"""Best-effort enumeration of non-loopback IPv4 addresses.