Skip to content

Commit fc42e6b

Browse files
authored
Merge pull request #63 from summerview1997/codex/webui-env-backend-preflight
Add WebUI env loading and backend preflight
2 parents 46b3207 + 30cc8a3 commit fc42e6b

2 files changed

Lines changed: 224 additions & 25 deletions

File tree

‎skillopt_webui/app.py‎

Lines changed: 135 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@
99
import json
1010
import os
1111
import signal
12+
import socket
1213
import subprocess
1314
import sys
1415
import threading
15-
import time
1616
from pathlib import Path
17+
from urllib.parse import urlparse
1718

1819
import gradio as gr
1920
import yaml
2021

22+
from skillopt.config import flatten_config
23+
from skillopt.config import load_config as load_merged_config
24+
2125
PROJECT_ROOT = Path(__file__).resolve().parent.parent
2226

2327

@@ -42,6 +46,131 @@ def config_to_display(cfg: dict) -> str:
4246
return yaml.dump(cfg, default_flow_style=False, sort_keys=False)
4347

4448

49+
def _can_connect_to_url(url: str, timeout: float = 0.5) -> bool:
50+
parsed = urlparse(url)
51+
host = parsed.hostname
52+
if not host:
53+
return False
54+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
55+
try:
56+
with socket.create_connection((host, port), timeout=timeout):
57+
return True
58+
except OSError:
59+
return False
60+
61+
62+
def _load_env_file(path: Path, env: dict[str, str]) -> None:
63+
for line in path.read_text().splitlines():
64+
line = line.strip()
65+
if line.startswith("export "):
66+
line = line[len("export "):].strip()
67+
if line and not line.startswith("#") and "=" in line:
68+
key, value = line.split("=", 1)
69+
env[key.strip()] = value.strip().strip("\"'")
70+
71+
72+
def build_training_env() -> dict[str, str]:
73+
"""Build the environment shared by preflight and the training subprocess."""
74+
env = os.environ.copy()
75+
env["PYTHONUNBUFFERED"] = "1"
76+
77+
dot_env = PROJECT_ROOT / ".env"
78+
if dot_env.is_file():
79+
_load_env_file(dot_env, env)
80+
81+
secrets_dir = PROJECT_ROOT / ".secrets"
82+
if secrets_dir.is_dir():
83+
for env_file in sorted(secrets_dir.glob("*.env")):
84+
_load_env_file(env_file, env)
85+
86+
# Propagate OPTIMIZER_* to base AZURE_OPENAI_* when base is missing,
87+
# so target/default endpoints inherit from optimizer config.
88+
for suffix in (
89+
"ENDPOINT", "API_VERSION", "AUTH_MODE", "MANAGED_IDENTITY_CLIENT_ID",
90+
"AD_SCOPE", "API_KEY",
91+
):
92+
base_key = f"AZURE_OPENAI_{suffix}"
93+
optimizer_key = f"OPTIMIZER_AZURE_OPENAI_{suffix}"
94+
if not env.get(base_key) and env.get(optimizer_key):
95+
env[base_key] = env[optimizer_key]
96+
return env
97+
98+
99+
def validate_training_config(
100+
config_path: str,
101+
overrides: dict,
102+
env: dict[str, str] | None = None,
103+
) -> str | None:
104+
"""Return an actionable preflight error, or None when training can start."""
105+
env = env or os.environ
106+
cfg_options = [
107+
f"{key}={value}" for key, value in overrides.items()
108+
if value is not None and value != ""
109+
]
110+
try:
111+
cfg = flatten_config(load_merged_config(str(PROJECT_ROOT / config_path), cfg_options))
112+
except Exception as exc:
113+
return f"❌ Invalid config: {exc}"
114+
115+
shared_endpoint = (
116+
cfg.get("azure_openai_endpoint")
117+
or cfg.get("azure_endpoint")
118+
or env.get("AZURE_OPENAI_ENDPOINT")
119+
)
120+
missing_openai_roles = []
121+
for role in ("optimizer", "target"):
122+
if cfg.get(f"{role}_backend") != "openai_chat":
123+
continue
124+
role_endpoint = (
125+
cfg.get(f"{role}_azure_openai_endpoint")
126+
or env.get(f"{role.upper()}_AZURE_OPENAI_ENDPOINT")
127+
or shared_endpoint
128+
)
129+
if not role_endpoint:
130+
missing_openai_roles.append(role)
131+
if missing_openai_roles:
132+
configured_backend = cfg.get("model_backend")
133+
detail = ""
134+
if configured_backend in {"qwen", "qwen_chat"}:
135+
detail = (
136+
"\nNote: model.backend is qwen, but explicit optimizer_backend/"
137+
"target_backend values are still openai_chat."
138+
)
139+
return (
140+
"❌ Model backend is not ready: missing Azure/OpenAI-compatible endpoint "
141+
f"for {', '.join(missing_openai_roles)}.\n"
142+
"Set model.azure_openai_endpoint (or AZURE_OPENAI_ENDPOINT), or change "
143+
"the role backends to the backend you intend to use."
144+
f"{detail}"
145+
)
146+
147+
qwen_failures = []
148+
qwen_shared = (
149+
cfg.get("qwen_chat_base_url")
150+
or env.get("QWEN_CHAT_BASE_URL")
151+
or "http://localhost:8000/v1"
152+
)
153+
for role in ("optimizer", "target"):
154+
if cfg.get(f"{role}_backend") != "qwen_chat":
155+
continue
156+
base_url = (
157+
cfg.get(f"{role}_qwen_chat_base_url")
158+
or env.get(f"{role.upper()}_QWEN_CHAT_BASE_URL")
159+
or qwen_shared
160+
)
161+
if not _can_connect_to_url(str(base_url)):
162+
qwen_failures.append(f"{role}={base_url}")
163+
if qwen_failures:
164+
return (
165+
"❌ Model backend is not ready: cannot connect to qwen_chat endpoint "
166+
f"for {', '.join(qwen_failures)}.\n"
167+
"Start your OpenAI-compatible Qwen/vLLM server, or set "
168+
"model.qwen_chat_base_url / OPTIMIZER_QWEN_CHAT_BASE_URL / "
169+
"TARGET_QWEN_CHAT_BASE_URL to the correct URL."
170+
)
171+
return None
172+
173+
45174
# ─── Training process management ────────────────────────────────────────────
46175

47176
class TrainingManager:
@@ -63,6 +192,11 @@ def start(self, config_path: str, overrides: dict) -> str:
63192
if self.running:
64193
return "⚠️ Training already running. Stop it first."
65194

195+
env = build_training_env()
196+
preflight_error = validate_training_config(config_path, overrides, env)
197+
if preflight_error:
198+
return preflight_error
199+
66200
cmd = [
67201
sys.executable, "scripts/train.py",
68202
"--config", config_path,
@@ -75,30 +209,6 @@ def start(self, config_path: str, overrides: dict) -> str:
75209
cmd.append("--cfg-options")
76210
cmd.extend(cfg_options)
77211

78-
env = os.environ.copy()
79-
env["PYTHONUNBUFFERED"] = "1"
80-
# Auto-load API credentials from .secrets/*.env
81-
secrets_dir = PROJECT_ROOT / ".secrets"
82-
if secrets_dir.is_dir():
83-
for env_file in sorted(secrets_dir.glob("*.env")):
84-
for line in env_file.read_text().splitlines():
85-
line = line.strip()
86-
if line and not line.startswith("#") and "=" in line:
87-
k, v = line.split("=", 1)
88-
env[k] = v
89-
# Propagate OPTIMIZER_* to base AZURE_OPENAI_* when base is missing,
90-
# so target/default endpoints inherit from optimizer config.
91-
_propagate = [
92-
("ENDPOINT", ""), ("API_VERSION", ""), ("AUTH_MODE", ""),
93-
("MANAGED_IDENTITY_CLIENT_ID", ""), ("AD_SCOPE", ""),
94-
("API_KEY", ""),
95-
]
96-
for suffix, _ in _propagate:
97-
base_key = f"AZURE_OPENAI_{suffix}"
98-
optimizer_key = f"OPTIMIZER_AZURE_OPENAI_{suffix}"
99-
if not env.get(base_key) and env.get(optimizer_key):
100-
env[base_key] = env[optimizer_key]
101-
102212
try:
103213
proc = subprocess.Popen(
104214
cmd,

‎tests/test_webui_env_preflight.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import pytest
2+
import yaml
3+
4+
pytest.importorskip("gradio")
5+
6+
from skillopt_webui import app as webui_app
7+
8+
9+
def _write_config(tmp_path, model):
10+
config_path = tmp_path / "config.yaml"
11+
config_path.write_text(
12+
yaml.safe_dump({
13+
"model": model,
14+
"env": {"name": "searchqa"},
15+
}),
16+
encoding="utf-8",
17+
)
18+
return str(config_path)
19+
20+
21+
def test_build_training_env_loads_project_dotenv(tmp_path, monkeypatch):
22+
monkeypatch.setattr(webui_app, "PROJECT_ROOT", tmp_path)
23+
(tmp_path / ".env").write_text(
24+
"\n".join([
25+
"export QWEN_CHAT_BASE_URL=http://qwen.example/v1",
26+
"QWEN_CHAT_MODEL=test-model",
27+
"QWEN_CHAT_API_KEY='secret-value'",
28+
]),
29+
encoding="utf-8",
30+
)
31+
32+
env = webui_app.build_training_env()
33+
34+
assert env["QWEN_CHAT_BASE_URL"] == "http://qwen.example/v1"
35+
assert env["QWEN_CHAT_MODEL"] == "test-model"
36+
assert env["QWEN_CHAT_API_KEY"] == "secret-value"
37+
38+
39+
def test_preflight_reports_missing_openai_chat_endpoint(tmp_path, monkeypatch):
40+
monkeypatch.delenv("AZURE_OPENAI_ENDPOINT", raising=False)
41+
monkeypatch.delenv("OPTIMIZER_AZURE_OPENAI_ENDPOINT", raising=False)
42+
monkeypatch.delenv("TARGET_AZURE_OPENAI_ENDPOINT", raising=False)
43+
config_path = _write_config(
44+
tmp_path,
45+
{
46+
"backend": "qwen",
47+
"optimizer_backend": "openai_chat",
48+
"target_backend": "openai_chat",
49+
},
50+
)
51+
52+
error = webui_app.validate_training_config(config_path, {})
53+
54+
assert "missing Azure/OpenAI-compatible endpoint for optimizer, target" in error
55+
assert "model.backend is qwen" in error
56+
57+
58+
def test_preflight_reports_unreachable_qwen_endpoint(tmp_path, monkeypatch):
59+
monkeypatch.setattr(webui_app, "_can_connect_to_url", lambda _url: False)
60+
config_path = _write_config(
61+
tmp_path,
62+
{
63+
"backend": "qwen",
64+
"optimizer_backend": "qwen_chat",
65+
"target_backend": "qwen_chat",
66+
"qwen_chat_base_url": "http://127.0.0.1:9/v1",
67+
},
68+
)
69+
70+
error = webui_app.validate_training_config(config_path, {})
71+
72+
assert "cannot connect to qwen_chat endpoint" in error
73+
assert "127.0.0.1:9" in error
74+
75+
76+
def test_preflight_accepts_reachable_qwen_endpoint(tmp_path, monkeypatch):
77+
seen_urls = []
78+
monkeypatch.setattr(webui_app, "_can_connect_to_url", lambda url: seen_urls.append(url) or True)
79+
config_path = _write_config(
80+
tmp_path,
81+
{
82+
"optimizer_backend": "qwen_chat",
83+
"target_backend": "qwen_chat",
84+
"qwen_chat_base_url": "http://qwen.example/v1",
85+
},
86+
)
87+
88+
assert webui_app.validate_training_config(config_path, {}) is None
89+
assert seen_urls == ["http://qwen.example/v1", "http://qwen.example/v1"]

0 commit comments

Comments
 (0)