99import json
1010import os
1111import signal
12+ import socket
1213import subprocess
1314import sys
1415import threading
15- import time
1616from pathlib import Path
17+ from urllib .parse import urlparse
1718
1819import gradio as gr
1920import yaml
2021
22+ from skillopt .config import flatten_config
23+ from skillopt .config import load_config as load_merged_config
24+
2125PROJECT_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+ "\n Note: 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
47176class 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 ,
0 commit comments