Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions omlx/admin/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@
"settings.server.host_public": "Open to all (0.0.0.0)",
"settings.server.host_custom": "Custom (caution)",
"settings.server.host_placeholder": "e.g. 192.168.1.100",
"settings.server.host_multi_hint": "Separate multiple addresses with commas",
"settings.server.port": "Port",
"settings.server.log_level": "Log Level",
"settings.server.log_level_error": "error",
Expand Down
1 change: 1 addition & 0 deletions omlx/admin/i18n/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@
"settings.server.host_public": "全体公開 (0.0.0.0)",
"settings.server.host_custom": "カスタム(注意)",
"settings.server.host_placeholder": "例: 192.168.1.100",
"settings.server.host_multi_hint": "複数アドレスはカンマで区切る",
"settings.server.port": "ポート",
"settings.server.log_level": "ログレベル",
"settings.server.log_level_error": "error",
Expand Down
1 change: 1 addition & 0 deletions omlx/admin/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@
"settings.server.host_public": "전체 공개 (0.0.0.0)",
"settings.server.host_custom": "사용자 지정 (주의)",
"settings.server.host_placeholder": "예: 192.168.1.100",
"settings.server.host_multi_hint": "여러 주소는 쉼표로 구분",
"settings.server.port": "포트",
"settings.server.log_level": "로그 레벨",
"settings.server.log_level_error": "error",
Expand Down
1 change: 1 addition & 0 deletions omlx/admin/i18n/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@
"settings.server.host_public": "對外開放(0.0.0.0)",
"settings.server.host_custom": "自訂(謹慎)",
"settings.server.host_placeholder": "例如 192.168.1.100",
"settings.server.host_multi_hint": "多個位址以逗號分隔",
"settings.server.port": "連接埠",
"settings.server.log_level": "日誌層級",
"settings.server.log_level_error": "error",
Expand Down
1 change: 1 addition & 0 deletions omlx/admin/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@
"settings.server.host_public": "对外开放(0.0.0.0)",
"settings.server.host_custom": "自定义(谨慎)",
"settings.server.host_placeholder": "例如 192.168.1.100",
"settings.server.host_multi_hint": "多个地址用逗号分隔",
"settings.server.port": "端口",
"settings.server.log_level": "日志级别",
"settings.server.log_level_error": "error",
Expand Down
11 changes: 11 additions & 0 deletions omlx/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2840,6 +2840,17 @@ async def update_global_settings(

# Apply server settings
if request.host is not None:
from ..utils.network import is_valid_bind_host

parts = [h.strip() for h in request.host.split(",") if h.strip()]
if not parts:
raise HTTPException(status_code=400, detail="Host cannot be empty")
for part in parts:
if not is_valid_bind_host(part):
raise HTTPException(
status_code=400,
detail=f"Invalid host: {part!r} (must be a hostname or IP address)",
)
global_settings.server.host = request.host
if request.port is not None:
global_settings.server.port = request.port
Expand Down
4 changes: 2 additions & 2 deletions omlx/admin/static/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@
window.location.href = '/admin';
} else {
const data = await response.json();
this.saveError = Array.isArray(data.detail) ? data.detail.join(', ') : (data.detail || window.t('js.error.save_settings_failed'));
this.saveError = Array.isArray(data.detail) ? data.detail.map(e => (e && typeof e === 'object') ? (e.msg || JSON.stringify(e)) : String(e)).join(', ') : (data.detail || window.t('js.error.save_settings_failed'));
// Reload settings to revert to server values
await this.loadGlobalSettings();
}
Expand Down Expand Up @@ -3728,7 +3728,7 @@
window.location.href = '/admin';
} else {
const data = await response.json();
alert(Array.isArray(data.detail) ? data.detail.join(', ') : (data.detail || 'Failed to save'));
alert(Array.isArray(data.detail) ? data.detail.map(e => (e && typeof e === 'object') ? (e.msg || JSON.stringify(e)) : String(e)).join(', ') : (data.detail || 'Failed to save'));
}
} catch (err) {
console.error('Failed to save HF mirror endpoint:', err);
Expand Down
26 changes: 15 additions & 11 deletions omlx/admin/templates/dashboard/_settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -223,17 +223,21 @@ <h3 class="text-2xl font-bold tracking-tight text-neutral-900">{{ t('settings.gl
<label class="text-sm text-neutral-700">{{ t('settings.server.host') }}</label>
<span class="px-1.5 py-0.5 text-[9px] font-medium rounded bg-amber-50 text-amber-600 border border-amber-200">{{ t('settings.global.restart_badge') }}</span>
</div>
<div class="flex items-center gap-2">
<select :value="hostMode"
@change="setHostMode($event.target.value)"
class="px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all bg-white">
<option value="localhost">{{ t('settings.server.host_localhost') }}</option>
<option value="public">{{ t('settings.server.host_public') }}</option>
<option value="custom">{{ t('settings.server.host_custom') }}</option>
</select>
<input x-show="hostMode === 'custom'" x-cloak
type="text" x-model="globalSettings.server.host" placeholder="{{ t('settings.server.host_placeholder') }}"
class="w-full sm:w-40 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<select :value="hostMode"
@change="setHostMode($event.target.value)"
class="px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all bg-white">
<option value="localhost">{{ t('settings.server.host_localhost') }}</option>
<option value="public">{{ t('settings.server.host_public') }}</option>
<option value="custom">{{ t('settings.server.host_custom') }}</option>
</select>
<input x-show="hostMode === 'custom'" x-cloak
type="text" x-model="globalSettings.server.host" placeholder="{{ t('settings.server.host_placeholder') }}"
class="w-full sm:w-40 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<p x-show="hostMode === 'custom'" x-cloak
class="text-xs text-neutral-400">{{ t('settings.server.host_multi_hint') }}</p>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
Expand Down
36 changes: 26 additions & 10 deletions omlx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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}"
Expand Down
3 changes: 2 additions & 1 deletion omlx/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", ["*"]),
Expand Down
19 changes: 19 additions & 0 deletions omlx/utils/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down