Skip to content

fix(dash): prefer a local chat server over the cloud default at startup#100

Merged
michaelroy-amd merged 7 commits into
mainfrom
fix/chat-startup-local-first
Jul 14, 2026
Merged

fix(dash): prefer a local chat server over the cloud default at startup#100
michaelroy-amd merged 7 commits into
mainfrom
fix/chat-startup-local-first

Conversation

@michaelroy-amd

Copy link
Copy Markdown
Member

Summary

  • Startup only single-probed DEFAULT_CHAT_BASE_URL (:8000) and then fell back to the ChatGPT cloud OAuth flow, even though the manual 'd' detect action 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 (e.g. a rocm serve default install) was invisible at startup, so the user landed in the cloud login flow instead of talking to their already-running local server.

Root cause

event_loop()'s startup block (crates/rocm-dash-tui/src/app/mod.rs) called detect_managed_chat() (registry-only) and then a single probe_endpoint() against one URL, instead of reusing chat::detect_local_chat() — the same registry-first + 3-port-probe + model-fetch detection the in-app 'd' action already used.

Fix

  • Startup now calls detect_local_chat() in the same branch that previously called detect_managed_chat() (gated on "no explicit chat_url/env URL configured" — CLI/config precedence is untouched).
  • Parallelized llm::detect_local_endpoint()'s port probe with std::thread::scope so a cold start with nothing listening still costs one PROBE_TIMEOUT (~300ms) instead of three sequential probes (~900ms worst case), keeping startup latency bounded.

Coordination with #97

Unmerged PR #97 (volen) also touches this startup branch, adding model discovery inside resolve_llm_config's own fallback path when chat_model is None. This change is conflict-minimal: it only swaps detect_managed_chat for detect_local_chat and leaves the resolve_llm_config(...) call itself untouched, so a rebase either direction should be a clean textual merge.

Relates to EAI-7347.

Test plan

  • cargo build -p rocm-dash-tui
  • cargo clippy -p rocm-dash-tui --all-targets --all-features (clean under -D warnings)
  • cargo test -p rocm-dash-tui -- --test-threads=1 (all green, incl. 3 new tests)
  • New test: llm::tests::detect_local_endpoint_prefers_lemonade_when_multiple_reachable — priority order preserved under parallel probing
  • New test: llm::tests::detect_local_endpoint_bounded_latency_when_nothing_listening — latency regression guard
  • New test: app::chat::tests::detect_local_chat_prefers_local_server_over_no_endpoint — startup-preference behavior (a listener on rocm serve's default port is detected with no executor/registry available)

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>
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>
…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>
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>
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>

@rominf rominf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Startup now reuses the full detect_local_chat path (registry + parallel well-known-port probe) so a local server correctly wins over the cloud default:

  • when detection finds a server, detected.is_some() short-circuits resolve_llm_config → local wins;
  • when none exists, detection_ran && detected.is_none() sets probe_ok = false and falls through to OAuth without a redundant re-probe (VLLM_ENDPOINT and DEFAULT_CHAT_BASE_URL resolve to the same loopback:8000 target);
  • the api-key gate in should_detect_local_chat correctly prevents detection's keyless config from dropping a configured credential.

The parallel probe preserves priority order via the order-preserving handles Vec + find_map, panicked probe threads are swallowed as false, and there are no hot-path unwraps. SPDX headers present; removed detect_managed_chat import is safe (now called transitively).

Approving.

@volen-silo

Copy link
Copy Markdown
Collaborator

🔴 Automated review · pr-review-watcher · f64bb40

Summary

At TUI startup, this swaps the old detect_managed_chat + single :8000 probe for the full detect_local_chat (registry-first + parallel 3-port probe + model fetch), so a local server on a non-default well-known port (e.g. rocm serve's :11435) is now preferred over the ChatGPT cloud default; probing is parallelized to keep cold-start latency at ~1×PROBE_TIMEOUT. Verdict: Approve — no correctness regressions found. Verified: cargo build, cargo test -- --test-threads=1 (all green), and cargo clippy --all-targets --all-features all pass on f64bb40; I traced every startup branch (server-present, no-server→OAuth fallback, chat_api_key set, explicit url/env) and confirmed no behavior regression — a server on :8000 is still found (via VLLM_ENDPOINT), the OAuth fallback still fires, config precedence and key-safety are preserved, and the thread::scope spawn-all-then-join with in-order find_map correctly preserves probe priority. Blocking: 0 · Non-blocking: 4.

🚫 Blocking (must fix before merge)

None.

Non-blocking

  • llm.rs:510detect_local_endpoint_bounded_latency_when_nothing_listening does not actually guard the regression it claims. On closed loopback ports connect_timeout returns ECONNREFUSED in ~microseconds, not after PROBE_TIMEOUT, so a revert to sequential probing still completes in <1ms — far under the 4×(1.2s) threshold (verified empirically). The guard only bites when connects genuinely time out (dropped SYNs), which loopback doesn't produce. Consider asserting order/behavior instead of wall-clock, or drop the timing assertion.
  • llm.rs:223 — the removed detect_local_endpoint_probes_rocm_serve_default_port test left detect_local_endpoint()'s actual candidate wiring (esp. ROCM_SERVE_ENDPOINT as 3rd fallback) untested; all new probe_first_reachable_* tests use synthetic ephemeral URLs and the sole live caller discards its result. A dropped/reordered/mistyped candidate constant would go uncaught.
  • app/mod.rs:1583-1662 — the new startup decision logic (detection_ranprobe_ok short-circuit → no_key_no_endpoint → OAuth-vs-local branch) is the actual fix but is untested glue; only the isolated pure helpers are covered. Extracting probe_ok/no_key_no_endpoint into pure testable fns would let this be unit-tested without the full terminal harness.
  • app/chat.rs:439 — test name detect_local_chat_prefers_local_server_over_no_endpoint overstates scope: it only proves the probe return value is wired into the config (its own doc-comment says so), not the startup preference the name implies. Rename to match (e.g. ..._wires_probed_endpoint_through).

Signed-off-by: Michael Roy <michael.roy@amd.com>
Signed-off-by: Michael Roy <michael.roy@amd.com>
@michaelroy-amd michaelroy-amd added this pull request to the merge queue Jul 14, 2026
Merged via the queue into main with commit 6c975c5 Jul 14, 2026
15 checks passed
@michaelroy-amd michaelroy-amd deleted the fix/chat-startup-local-first branch July 14, 2026 17:01
michaelroy-amd added a commit that referenced this pull request Jul 16, 2026
Reconcile PR #97 (configured-endpoint served-model discovery) with main's
PR #100 startup local-first detection (EAI-7347).

Conflicts in crates/rocm-dash-tui/src/app/{chat.rs,mod.rs} resolved as a
union that keeps both behaviors:

- Preserve main's local-engine detection: should_detect_local_chat,
  StartupChatOutcome/startup_chat_outcome, detect_local_chat_with_probe,
  and the #89 Press-only overlay key gate (is_actionable_key).
- Port PR #97's /v1/models discovery for a *configured* chat endpoint into
  a new helper, chat::discover_configured_chat_model, wired only on the
  StartupChatOutcome::Configured path.

Startup matrix (verified by unit tests in app::chat::tests):
- Configured URL + no explicit model + reachable -> adopt served /v1/models id.
- Configured URL + explicit model            -> config precedence, never overridden.
- Configured URL + unreachable               -> never probed, never replaced
  (no fetch timeout; an unreachable explicit CLI/env URL is left untouched).
- Local detected / OAuth paths unchanged from main.

RED before the port: configured_endpoint_adopts_served_model_when_no_model_set
failed against the merged-main stub (got "local-model", expected served id);
GREEN after wiring the helper. Full workspace tests, clippy -D warnings, and
scripts/smoke_local.py all pass.

Signed-off-by: Michael Roy <michael.roy@amd.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants