Skip to content

Commit 6c975c5

Browse files
fix(dash): prefer a local chat server over the cloud default at startup (#100)
* fix(dash): prefer a local chat server over the cloud default at startup Startup only single-probed DEFAULT_CHAT_BASE_URL (:8000) before falling back to the ChatGPT cloud OAuth flow, even though the manual 'd' detect path already knew how to find a local engine on any of its well-known ports (Lemonade :13305, vLLM :8000, rocm serve :11435) plus the managed-services registry. A server running on any port other than :8000 was invisible at startup and users landed in the cloud login flow instead. Reuse detect_local_chat() (registry-first, then the 3-port probe, then a best-effort model fetch) in the startup branch that already gated on "no explicit chat_url/env_url configured" — the same condition that previously ran only detect_managed_chat(). Parallelize llm::detect_local_endpoint()'s port probe (std::thread::scope) so a cold start with nothing listening still costs one PROBE_TIMEOUT (~300ms) instead of three, keeping startup latency bounded. Unmerged PR #97 also touches this startup branch (model discovery inside resolve_llm_config's own fallback when chat_model is None); this change is conflict-minimal — it swaps detect_managed_chat for detect_local_chat without touching the resolve_llm_config call itself. Relates to EAI-7347. Signed-off-by: Michael Roy <michael.roy@amd.com> * fix(chat): do not run local-detection swap when an api key is configured Startup local-engine detection returns a keyless detected_llm_config (api_key/auth_header forced to None). Firing it whenever no explicit URL was set — even when the user configured OPENAI_API_KEY / ROCMDASH_CHAT_API_KEY — silently dropped that credential and produced a 401 at request time. Gate detection on chat_api_key.is_none() as well (extracted into the pure should_detect_local_chat helper for unit testing): a configured key means "use my configured backend", so skip the swap and let resolve_llm_config carry the key through its normal precedence. Also skip the redundant fallback probe: when detection ran and found nothing it already probed the well-known vLLM :8000 port (== DEFAULT_CHAT_BASE_URL), so re-probing that same target just burned another PROBE_TIMEOUT on a no-server cold start; treat it as unreachable directly. Relates to EAI-7347. Signed-off-by: Michael Roy <michael.roy@amd.com> * fix(dash): guard well-known ports free in local-detect port-priority tests detect_local_chat_prefers_local_server_over_no_endpoint (and the sibling detect_local_endpoint_probes_rocm_serve_default_port) only bound the rocm-serve port and assumed the higher-priority Lemonade/vLLM ports were unreachable. On the Windows CI runner that isn't guaranteed, so an ambient listener on :13305 or :8000 made detection return that endpoint instead of rocm-serve's, failing the assertion. Both tests now bind-and-release the higher-priority ports first to confirm they're free before asserting on the rocm-serve fallback. Signed-off-by: Michael Roy <michael.roy@amd.com> * fix(dash): make local-detect priority tests port-independent The previous fix (bind-then-release the higher-priority ports before asserting) still flaked on the Windows CI runner: something answered on the Lemonade port during the probe despite the guard, most likely an ambient listener the test doesn't control. TCP-connect-based detection means any listener on a well-known port — even a bare guard listener held open, as the very first attempt at this fix did — registers as "reachable", so real ports can't be made deterministic from a test. Split the port-priority logic in llm.rs into probe_first_reachable, tested directly against OS-assigned ephemeral ports instead of the real well-known ports. Gave detect_local_chat an injectable probe seam (detect_local_chat_with_probe) so its own test can verify the no-managed-executor wiring without depending on any real network state. Signed-off-by: Michael Roy <michael.roy@amd.com> * fix(dash): widen latency margin in detect_local_endpoint timing test detect_local_endpoint_bounded_latency_when_nothing_listening's 2x PROBE_TIMEOUT margin was still too tight for the Windows CI runner (observed 624ms against a 600ms threshold). A true sequential-probing regression takes ~3x PROBE_TIMEOUT, so widen the margin to 4x to absorb scheduling jitter on slower CI machines while still catching that regression. Signed-off-by: Michael Roy <michael.roy@amd.com> * test(dash): strengthen local chat startup coverage Signed-off-by: Michael Roy <michael.roy@amd.com> --------- Signed-off-by: Michael Roy <michael.roy@amd.com>
1 parent 5d73191 commit 6c975c5

3 files changed

Lines changed: 269 additions & 42 deletions

File tree

crates/rocm-dash-tui/src/app/chat.rs

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,16 +228,24 @@ pub(super) async fn detect_managed_chat(
228228
/// Lemonade/vLLM endpoints when no managed service is available.
229229
pub(super) async fn detect_local_chat(
230230
executor: Option<crate::tool_exec::SharedRocmToolExecutor>,
231+
) -> Option<crate::llm::LlmConfig> {
232+
detect_local_chat_with_probe(executor, crate::llm::detect_local_endpoint).await
233+
}
234+
235+
/// Same as [`detect_local_chat`], but with the well-known-port probe passed
236+
/// in rather than hard-coded, so a test can substitute a deterministic probe
237+
/// instead of depending on the real well-known ports being free on the
238+
/// machine running the test.
239+
async fn detect_local_chat_with_probe(
240+
executor: Option<crate::tool_exec::SharedRocmToolExecutor>,
241+
probe: impl Fn() -> Option<&'static str> + Send + 'static,
231242
) -> Option<crate::llm::LlmConfig> {
232243
if let Some(cfg) = detect_managed_chat(executor).await {
233244
return Some(cfg);
234245
}
235246

236247
// TCP probe is blocking; keep it off the async reactor.
237-
let base = tokio::task::spawn_blocking(crate::llm::detect_local_endpoint)
238-
.await
239-
.ok()
240-
.flatten()?;
248+
let base = tokio::task::spawn_blocking(probe).await.ok().flatten()?;
241249

242250
// Best-effort model query; fall back to the neutral default on any failure.
243251
let model = fetch_first_model(base)
@@ -246,6 +254,44 @@ pub(super) async fn detect_local_chat(
246254
Some(crate::llm::detected_llm_config(base, &model))
247255
}
248256

257+
/// Whether startup should run the local-engine auto-detection swap.
258+
///
259+
/// Detection produces a keyless [`crate::llm::detected_llm_config`], so it may
260+
/// only fire when the user configured NO endpoint (URL/env URL) AND NO api key
261+
/// — otherwise the swap would silently drop a configured credential and 401 at
262+
/// request time. Pure; the anchor for the startup-gate unit test.
263+
pub(super) const fn should_detect_local_chat(
264+
chat_url: Option<&str>,
265+
chat_env_url: Option<&str>,
266+
chat_api_key: Option<&str>,
267+
) -> bool {
268+
chat_url.is_none() && chat_env_url.is_none() && chat_api_key.is_none()
269+
}
270+
271+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272+
pub(super) enum StartupChatOutcome {
273+
Local,
274+
OAuth,
275+
Configured,
276+
}
277+
278+
/// Select the startup backend path after optional local detection.
279+
///
280+
/// A successful detection is already reachable. A detection miss means the
281+
/// well-known endpoints were exhausted and startup should proceed to OAuth.
282+
/// When detection was gated off, the configured target still needs its normal
283+
/// liveness probe before backend construction.
284+
pub(super) const fn startup_chat_outcome(
285+
detection_ran: bool,
286+
detected: bool,
287+
) -> StartupChatOutcome {
288+
match (detection_ran, detected) {
289+
(_, true) => StartupChatOutcome::Local,
290+
(true, false) => StartupChatOutcome::OAuth,
291+
(false, false) => StartupChatOutcome::Configured,
292+
}
293+
}
294+
249295
/// Persist an accepted local endpoint to the user's `config.toml`: load the
250296
/// existing config (or defaults), set `tui.chat_url`/`tui.chat_model`, and write
251297
/// it back. Best-effort — returns a human error string on failure.
@@ -408,4 +454,64 @@ mod tests {
408454
let env = services_envelope(serde_json::json!([service("vllm", "", "ready", "m", 100)]));
409455
assert_eq!(pick_managed_chat_endpoint(&env), None);
410456
}
457+
458+
/// EAI-7347: startup now reuses `detect_local_chat` (the same detection the
459+
/// manual 'd' path already used) instead of a single well-known-port probe,
460+
/// so a local server on a non-default well-known port (e.g. `rocm serve`'s
461+
/// :11435) is preferred over falling back to the ChatGPT cloud default.
462+
#[tokio::test]
463+
async fn detect_local_chat_wires_probed_endpoint_through() {
464+
// Priority order among the well-known ports (Lemonade > vLLM > rocm
465+
// serve's default) is covered deterministically in `llm.rs`'s own
466+
// tests, against OS-assigned ephemeral ports. This test's job is
467+
// narrower: prove `detect_local_chat`'s no-managed-executor path
468+
// wires whatever the well-known-port probe finds into the resulting
469+
// config, rather than falling back to the cloud default — so the
470+
// probe is injected rather than exercised against real ports (real
471+
// ports it doesn't own would make this flake on a CI runner where an
472+
// ambient listener happens to occupy one of them).
473+
const PROBED: &str = "http://127.0.0.1:1/v1";
474+
let cfg = detect_local_chat_with_probe(None, || Some(PROBED))
475+
.await
476+
.expect("local server detected");
477+
assert_eq!(cfg.base_url, PROBED);
478+
}
479+
480+
#[test]
481+
fn should_detect_only_when_no_url_env_or_key_configured() {
482+
// The clean slate: nothing configured → auto-detect a local engine.
483+
assert!(should_detect_local_chat(None, None, None));
484+
// A configured api key must SUPPRESS the swap — detection returns a
485+
// keyless config and would otherwise silently drop the key (401).
486+
assert!(!should_detect_local_chat(None, None, Some("sk-configured")));
487+
// An explicit URL or env URL also suppresses it (config precedence).
488+
assert!(!should_detect_local_chat(
489+
Some("http://cfg:1/v1"),
490+
None,
491+
None
492+
));
493+
assert!(!should_detect_local_chat(
494+
None,
495+
Some("http://env:2/v1"),
496+
None
497+
));
498+
}
499+
500+
#[test]
501+
fn startup_uses_detected_local_endpoint() {
502+
assert_eq!(startup_chat_outcome(true, true), StartupChatOutcome::Local);
503+
}
504+
505+
#[test]
506+
fn startup_uses_oauth_after_empty_detection() {
507+
assert_eq!(startup_chat_outcome(true, false), StartupChatOutcome::OAuth);
508+
}
509+
510+
#[test]
511+
fn startup_uses_configured_endpoint_when_detection_is_skipped() {
512+
assert_eq!(
513+
startup_chat_outcome(false, false),
514+
StartupChatOutcome::Configured
515+
);
516+
}
411517
}

crates/rocm-dash-tui/src/app/mod.rs

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ mod slash;
3939
mod summary;
4040

4141
use chat::{
42-
build_chat_agent, build_local_agent, detect_local_chat, detect_managed_chat,
43-
persist_chat_endpoint,
42+
StartupChatOutcome, build_chat_agent, build_local_agent, detect_local_chat,
43+
persist_chat_endpoint, startup_chat_outcome,
4444
};
4545
use summary::{parse_plan_result, summarize_json_value, summarize_slash_tool};
4646

@@ -1562,8 +1562,34 @@ async fn event_loop(terminal: &mut Tui, args: &ResolvedArgs) -> color_eyre::Resu
15621562
// NOT override an explicitly configured `chat_url`/env URL, so config
15631563
// precedence is preserved (we only consult the registry when neither is
15641564
// set, i.e. where the well-known default would otherwise be probed).
1565-
let managed = if args.chat_url.is_none() && args.chat_env_url.is_none() {
1566-
detect_managed_chat(state.tool_executor.clone()).await
1565+
// When neither an explicit URL (CLI/config) nor an env URL is set, run
1566+
// the SAME full local-engine detection the manual 'd' path uses:
1567+
// registry-first (an engine we launched ourselves, on whatever port it
1568+
// bound), then a probe of the well-known Lemonade/vLLM/rocm-serve
1569+
// ports (parallelized — see `llm::detect_local_endpoint` — so a cold
1570+
// start with no server doesn't pay 3x the probe timeout), plus a
1571+
// best-effort served-model fetch. This is what lets a local server win
1572+
// over the ChatGPT cloud default at startup instead of only the single
1573+
// well-known :8000 port that a bare `resolve_llm_config` probe covers.
1574+
//
1575+
// NOTE: unmerged PR #97 also touches this branch (model discovery when
1576+
// `chat_model` is None, inside `resolve_llm_config`'s own fallback
1577+
// path) — this change is conflict-minimal by leaving the
1578+
// `resolve_llm_config` call below untouched.
1579+
//
1580+
// Gate on `chat_api_key.is_none()` too: local detection returns a
1581+
// keyless `detected_llm_config` (api_key/auth_header forced to None),
1582+
// so firing it when the user configured a key would SILENTLY DROP that
1583+
// key and 401 at request time. A configured key means "use my
1584+
// configured backend", so skip the swap and let `resolve_llm_config`
1585+
// carry the key through its normal precedence.
1586+
let detection_ran = chat::should_detect_local_chat(
1587+
args.chat_url.as_deref(),
1588+
args.chat_env_url.as_deref(),
1589+
args.chat_api_key.as_deref(),
1590+
);
1591+
let detected = if detection_ran {
1592+
detect_local_chat(state.tool_executor.clone()).await
15671593
} else {
15681594
None
15691595
};
@@ -1572,17 +1598,23 @@ async fn event_loop(terminal: &mut Tui, args: &ResolvedArgs) -> color_eyre::Resu
15721598
.clone()
15731599
.or_else(|| args.chat_env_url.clone())
15741600
.unwrap_or_else(|| crate::llm::DEFAULT_CHAT_BASE_URL.to_string());
1575-
// A managed endpoint is already readiness-verified; otherwise TCP-probe.
1576-
let probe_ok = if managed.is_some() {
1577-
true
1578-
} else {
1579-
tokio::task::spawn_blocking(move || {
1601+
// A detected endpoint (managed or probed) is already verified. When
1602+
// detection ran and found nothing it already probed the well-known
1603+
// vLLM :8000 port (== `DEFAULT_CHAT_BASE_URL`), so re-probing the same
1604+
// fallback target here is redundant and just burns another probe
1605+
// timeout on a cold start — treat that as unreachable directly.
1606+
// Otherwise (an explicit URL/env/key path) TCP-probe the target.
1607+
let startup_outcome = startup_chat_outcome(detection_ran, detected.is_some());
1608+
let probe_ok = match startup_outcome {
1609+
StartupChatOutcome::Local => true,
1610+
StartupChatOutcome::OAuth => false,
1611+
StartupChatOutcome::Configured => tokio::task::spawn_blocking(move || {
15801612
crate::llm::probe_endpoint(&probe_target, crate::llm::PROBE_TIMEOUT)
15811613
})
15821614
.await
1583-
.unwrap_or(false)
1615+
.unwrap_or(false),
15841616
};
1585-
let llm = managed.or_else(|| {
1617+
let llm = detected.or_else(|| {
15861618
crate::llm::resolve_llm_config(
15871619
args.chat_url.as_deref(),
15881620
args.chat_model.as_deref(),
@@ -1599,10 +1631,7 @@ async fn event_loop(terminal: &mut Tui, args: &ResolvedArgs) -> color_eyre::Resu
15991631
// ChatGPT OAuth default (device-code login surfaced in the chat tab).
16001632
// This restores the no-key login the vendored Codex path provided; it
16011633
// takes NO api_key (env-only invariant untouched — OAuth, not a key).
1602-
let no_key_no_endpoint = !probe_ok
1603-
&& args.chat_api_key.is_none()
1604-
&& args.chat_url.is_none()
1605-
&& args.chat_env_url.is_none();
1634+
let no_key_no_endpoint = startup_outcome == StartupChatOutcome::OAuth;
16061635
if no_key_no_endpoint {
16071636
let oauth_tx = chat_tx.clone();
16081637
crate::agent::ChatGptAgentClient::new(

crates/rocm-dash-tui/src/llm.rs

Lines changed: 115 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -188,20 +188,49 @@ pub fn probe_endpoint(base_url: &str, timeout: Duration) -> bool {
188188
false
189189
}
190190

191-
/// Probe the known local serving endpoints in priority order (Lemonade, then
192-
/// vLLM, then `rocm serve`'s non-managed default) and return the first
193-
/// reachable one.
191+
/// Probe `candidates` (in priority order) concurrently and return the
192+
/// highest-priority one that answered.
194193
///
195-
/// Used by the TUI's in-app "detect a local engine" action so the user need not run the CLI skill first.
196-
/// TCP-only (no HTTP); never blocks longer than [`PROBE_TIMEOUT`] per candidate.
194+
/// Split out from [`detect_local_endpoint`] so priority-order behavior is
195+
/// testable against OS-assigned ephemeral ports instead of the real
196+
/// well-known ports, which may be occupied by an unrelated ambient listener
197+
/// on the machine running the test. Probing all candidates in parallel
198+
/// (rather than sequentially) bounds the worst case (no server listening) to
199+
/// one [`PROBE_TIMEOUT`] instead of one per candidate. TCP-only (no HTTP);
200+
/// never blocks longer than [`PROBE_TIMEOUT`] total.
201+
fn probe_first_reachable<'a, P>(candidates: &[&'a str], probe: P) -> Option<&'a str>
202+
where
203+
P: Fn(&str, Duration) -> bool + Sync,
204+
{
205+
std::thread::scope(|scope| {
206+
// The collect is load-bearing, not needless: every probe must be
207+
// *spawned* before any is *joined*, or they run one at a time and the
208+
// whole point (bounding total latency to one PROBE_TIMEOUT) is lost.
209+
#[allow(clippy::needless_collect)]
210+
let handles: Vec<_> = candidates
211+
.iter()
212+
.map(|ep| (*ep, scope.spawn(|| probe(ep, PROBE_TIMEOUT))))
213+
.collect();
214+
handles
215+
.into_iter()
216+
.find_map(|(ep, handle)| handle.join().unwrap_or(false).then_some(ep))
217+
})
218+
}
219+
220+
const LOCAL_ENDPOINT_CANDIDATES: [&str; 3] = [
221+
crate::skills::LEMONADE_ENDPOINT,
222+
crate::skills::VLLM_ENDPOINT,
223+
crate::skills::ROCM_SERVE_ENDPOINT,
224+
];
225+
226+
/// Probe the known local serving endpoints (Lemonade, vLLM, then `rocm
227+
/// serve`'s non-managed default) concurrently and return the highest-priority
228+
/// one that answered.
229+
///
230+
/// Used by the TUI's in-app "detect a local engine" action, and by chat
231+
/// startup, so the user need not run the CLI skill first.
197232
pub fn detect_local_endpoint() -> Option<&'static str> {
198-
[
199-
crate::skills::LEMONADE_ENDPOINT,
200-
crate::skills::VLLM_ENDPOINT,
201-
crate::skills::ROCM_SERVE_ENDPOINT,
202-
]
203-
.into_iter()
204-
.find(|ep| probe_endpoint(ep, PROBE_TIMEOUT))
233+
probe_first_reachable(&LOCAL_ENDPOINT_CANDIDATES, probe_endpoint)
205234
}
206235

207236
/// Pick the first model id from an OpenAI-compatible `/v1/models` response (`{"data":[{"id":"…"}]}`).
@@ -438,20 +467,83 @@ mod tests {
438467
assert!(!probe_endpoint("not a url", Duration::from_millis(100)));
439468
}
440469

470+
/// Binds an OS-assigned ephemeral port and returns its listener (kept
471+
/// alive so the port stays reachable) plus its `http://host:port` URL.
472+
///
473+
/// Ephemeral ports are used instead of the real well-known ports
474+
/// (Lemonade/vLLM/rocm serve) so priority-order tests are deterministic:
475+
/// an ambient listener elsewhere on the machine (e.g. a real local
476+
/// engine, or — on some CI runners — a pre-installed one) can otherwise
477+
/// make well-known-port-based tests flake by answering a probe the test
478+
/// didn't intend to exercise.
479+
fn ephemeral_endpoint() -> (std::net::TcpListener, String) {
480+
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind ephemeral port");
481+
let url = format!("http://{}", listener.local_addr().expect("local_addr"));
482+
(listener, url)
483+
}
484+
441485
#[test]
442-
fn detect_local_endpoint_probes_rocm_serve_default_port() {
443-
// A listener on rocm serve's (non-managed) default port must be picked
444-
// up by the fallback probe, not just Lemonade/vLLM's ports.
445-
let Ok(listener) =
446-
std::net::TcpListener::bind(("127.0.0.1", crate::skills::ROCM_SERVE_PORT))
447-
else {
448-
// Port already bound in this environment; skip rather than flake.
449-
return;
486+
fn probe_first_reachable_prefers_earlier_candidate_when_multiple_reachable() {
487+
// Two reachable candidates: priority order (first in the list) must
488+
// win even though probes run concurrently, not sequentially.
489+
let (_first, first_url) = ephemeral_endpoint();
490+
let (_second, second_url) = ephemeral_endpoint();
491+
assert_eq!(
492+
probe_first_reachable(&[first_url.as_str(), second_url.as_str()], probe_endpoint),
493+
Some(first_url.as_str())
494+
);
495+
}
496+
497+
#[test]
498+
fn probe_first_reachable_skips_unreachable_earlier_candidates() {
499+
// The first candidate (port 1) is essentially never open; the probe
500+
// must fall through to the second, reachable one.
501+
let (_listener, url) = ephemeral_endpoint();
502+
assert_eq!(
503+
probe_first_reachable(&["http://127.0.0.1:1", url.as_str()], probe_endpoint,),
504+
Some(url.as_str())
505+
);
506+
}
507+
508+
#[test]
509+
fn probe_first_reachable_none_when_nothing_listening() {
510+
assert_eq!(
511+
probe_first_reachable(&["http://127.0.0.1:1"], probe_endpoint),
512+
None
513+
);
514+
}
515+
516+
#[test]
517+
fn probe_first_reachable_starts_all_probes_concurrently() {
518+
use std::sync::atomic::{AtomicUsize, Ordering};
519+
520+
let active = AtomicUsize::new(0);
521+
let max_active = AtomicUsize::new(0);
522+
let probe = |_: &str, _: Duration| {
523+
let now_active = active.fetch_add(1, Ordering::SeqCst) + 1;
524+
max_active.fetch_max(now_active, Ordering::SeqCst);
525+
std::thread::sleep(Duration::from_millis(50));
526+
active.fetch_sub(1, Ordering::SeqCst);
527+
false
450528
};
529+
530+
assert_eq!(probe_first_reachable(&["a", "b", "c"], probe), None);
531+
assert_eq!(
532+
max_active.load(Ordering::SeqCst),
533+
3,
534+
"all probes must be in flight before any probe is joined"
535+
);
536+
}
537+
538+
#[test]
539+
fn local_endpoint_candidates_preserve_supported_order() {
451540
assert_eq!(
452-
detect_local_endpoint(),
453-
Some(crate::skills::ROCM_SERVE_ENDPOINT)
541+
LOCAL_ENDPOINT_CANDIDATES,
542+
[
543+
crate::skills::LEMONADE_ENDPOINT,
544+
crate::skills::VLLM_ENDPOINT,
545+
crate::skills::ROCM_SERVE_ENDPOINT,
546+
]
454547
);
455-
drop(listener);
456548
}
457549
}

0 commit comments

Comments
 (0)