fix(chat): revalidate a stale persisted chat_url and fall back to a live endpoint - #105
fix(chat): revalidate a stale persisted chat_url and fall back to a live endpoint#105michaelroy-amd wants to merge 1 commit into
Conversation
rominf
left a comment
There was a problem hiding this comment.
LGTM. The stale-persisted-URL revalidation is well-guarded: the swap only fires on !probe_ok and a genuinely-different (trailing-/ and /v1 normalized) URL, and is gated on chat_api_key.is_none() && chat_auth_header.is_none() so a configured remote gateway that merely times out won't silently fall back to a keyless local engine. No loop (re-detecting the same URL returns None, so no spurious "server changed" notice), no panic (unwrap_or(false) / .ok()), and correct precedence (managed.or_else(stale).or_else(resolve_llm_config) — the CLI>config>env>probe contract is untouched). Dropping chat_model on swap is correct since it's a different server. Good test coverage including the normalization cases.
Note: on the degraded path (persisted URL dead) startup now does a bounded one-time extra probe (~300ms + detect_local_chat) before the first frame — worth a sentence in the PR body.
Approving.
|
🔴 Automated review · pr-review-watcher · ed0b00e Summary
Verified as correct (the load-bearing claims)
Non-blocking suggestions
Tradeoffs (deliberate choices worth confirming, not change requests)
Positive signals
|
…ale chat_url recovery) Layer PR #105's stale persisted-URL recovery onto PR #97's configured-endpoint model discovery (which already includes current main). Manually resolve the chat.rs/mod.rs conflicts, preserving both behaviors: - PR #97: discover_configured_chat_model() adopts a served /v1/models id for a reachable configured endpoint that has no explicit model (probe_ok path). - PR #105 (EAI-7360): stale_chat_url_replacement() swaps a dead persisted tui.chat_url for a genuinely-different live local engine on the Configured + !probe_ok path, emits a "server changed" notice pointing at `/detect save`, and normalize_base_url() treats trailing slash and /v1 formatting as the same endpoint. Gated on chat_url.is_some() + no api_key/auth_header so env URLs and authenticated remote gateways are never silently replaced. The two paths are mutually exclusive on the Configured branch's probe_ok, so they compose without interference; resolve_llm_config's CLI>config>env>probe precedence is untouched. Matrix verified: no URL/model -> local detection; reachable configured -> /v1/models discovery; stale persisted -> live replacement + notice; unreachable env/explicit URL -> fail honestly; explicit auth -> no silent fallback. Signed-off-by: Michael Roy <michael.roy@amd.com>
rominf
left a comment
There was a problem hiding this comment.
Automated review: re-reviewed the delta since my approval at ed0b00e5 through current head e8f896c6. The unreviewed code is the hand-resolved merge of PR #97 into this branch, concentrated in app/chat.rs and app/mod.rs.
I traced the rewritten startup path: the llm rebinding preserves precedence; configured-endpoint model discovery and stale-URL replacement are mutually exclusive on probe success; neither clobbers the other; detection is not repeated; and the previously approved helpers and tests remain intact. I found no blocking code defect in the delta.
Non-blocking:
- The PR body still says no overlapping hunks were expected, but both files required manual conflict resolution. Please update that coordination note.
- The helpers are unit-tested independently, but no integration test covers their composed ordering in
event_loop, where the merge occurred. That would be useful follow-up coverage.
Merge readiness: the PR currently conflicts with main, and the GPU and Strix Halo/Windows E2E checks are red. I could not verify those failures as infrastructure flakes from the available log tail, so they should be inspected or rerun before merge.
…ive endpoint A persisted `tui.chat_url` survives across dash launches once written (e.g. via the in-TUI "use & save" flow). If the server behind it is later stopped or replaced -- most commonly a managed vLLM/Lemonade instance relaunched on a different port -- startup passed the stale URL straight into `resolve_llm_config`, whose `cli_url.or(cfg_url).or(env_url)` returns it unconditionally regardless of reachability. The chat backend was then built against a dead endpoint with no on-screen indication; the user only found out from a failed completion request. `app/chat.rs` gains two pure, I/O-free helpers: - `stale_chat_url_replacement` -- given the configured base URL, whether its probe succeeded, and an already-detected live `LlmConfig`, returns `Some(live)` only when the configured endpoint is unreachable and a genuinely different live endpoint exists. - `normalize_base_url` -- drops a trailing `/` and `/v1` so a formatting-only difference is not mistaken for a server change. `app/mod.rs` wires this into the `Configured`-but-unreachable branch of the startup path, after `discover_configured_chat_model`. The swap additionally requires no configured api key and no auth header: the replacement is a keyless `detected_llm_config`, so a remote gateway URL plus `OPENAI_API_KEY` that merely TCP-times-out must never silently fall back to a local keyless engine and drop the credential. A `ChatTurn::system` notice points at `/detect save` to persist the replacement. `resolve_llm_config`'s CLI>config>env>probe precedence is untouched -- the substitution happens at the call site by swapping in a different `LlmConfig`, and that function's existing precedence tests are unmodified. Also corrects the `ResolvedArgs::chat_url` doc comment, which claimed the field was a "CLI-flag value already merged over config". There is no `--chat-url` flag; `apps/rocm/src/dash.rs` sources it solely from persisted `tui.chat_url`, which is precisely the tier this change revalidates. Relates to EAI-7360. Signed-off-by: Michael Roy <michael.roy@amd.com>
e8f896c to
b8a140b
Compare
rominf
left a comment
There was a problem hiding this comment.
The core idea is right and the scope is tight. Two things need addressing before this lands: the remediation the notice tells the user to run doesn't work, and the change hand-rolls a narrower version of a mechanism that already exists and would make it work.
Blocking
1. The notice instructs a command that can't work — crates/rocm-dash-tui/src/app/mod.rs:1820-1826
The message ends with "Run /detect save to persist it." But /detect save is gated on chat_detect_offer.is_some() (app/slash.rs:491-496), and this path never populates it — it calls state.set_chat_config(llm, ...) (mod.rs:1828), which only sets chat_consent/chat_llm (app/chat.rs:25-32). A user who follows the instruction gets back "no detected endpoint to act on yet — run /detect first to probe for a local engine." So the swap can't actually be persisted, which is the one thing a user hitting this will want.
2. This duplicates the existing detect-offer flow — mod.rs:1815-1828 vs mod.rs:1143-1166
set_detect_result(Some(cfg)) already stores the offer and pushes a transcript message naming endpoint and model with the working /detect accept | save | dismiss sub-commands. Routing the detected live config through it would reuse the tested reducer, make (1) disappear, and give the user accept/dismiss instead of a silent fait-accompli swap. As written there are two divergent paths for "we found a different local engine", which will drift. (Note set_detect_result only pushes the message when consent is Accepted — worth checking that fits the startup path, but the offer is set unconditionally, which is what /detect save needs.)
3. Doc comment contradicts the code — app/chat.rs:305-306
"…this only ever supplies a replacement config before that call runs." The substitution happens after resolve_llm_config (mod.rs:1766 → 1816 → 1827), and mod.rs:1799 in this same commit correctly says "after that call." One of the two is wrong.
Non-blocking
4. An explicit chat_model is silently dropped on the recovery path — mod.rs:1827, chat.rs:311-321. stale_chat_url_replacement returns the detection result verbatim, whose model comes from /v1/models or the default. The sibling reachable path (discover_configured_chat_model, chat.rs:413-426) goes out of its way to honor an explicit model. Probably the right call for a different server, but it's an undocumented asymmetry and it isn't covered in the long precedence comment above.
5. The adjacent doc line has the exact bug this PR fixes one line above — mod.rs:87-88. The chat_url doc correction is accurate; chat_model still says "CLI-flag value already merged over config" and there's no --chat-model flag in apps/rocm/src/main.rs either. One-line fix, in scope.
6. Dead condition and dead argument — mod.rs:1809-1816. should_detect_local_chat returns false whenever chat_url.is_some() (chat.rs:268), so the == Configured check is redundant given the args.chat_url.is_some() conjunct. And stale_chat_url_replacement(&probe_target, probe_ok, live) passes probe_ok from inside an if !probe_ok branch, so chat.rs:316-318's early return is unreachable from the only production caller — it exists for one unit test. Defensible as a general helper signature, but worth a note.
7. localhost vs 127.0.0.1 reads as a server change — chat.rs:327-330. normalize_base_url compares raw strings; the default base URL uses 127.0.0.1 while the detection candidates use localhost. Same server, different spelling, spurious swap plus notice. Unlikely in practice since persisted URLs come from the candidates, but it's precisely the false-positive this helper exists to prevent, and it's untested.
8. A 300ms TCP-connect probe now has a larger blast radius — llm.rs:176-188. probe_endpoint only does connect_timeout; it says nothing about /v1/chat/completions working. Previously a false "unreachable" just meant "skip the default fallback"; now it substitutes a different endpoint. A server still binding, or an IPv6/IPv4 ordering quirk, can take a user off their real endpoint. Worth a retry or an HTTP-level check before acting on a negative probe.
9. The risky part is untested — mod.rs:1809-1828. All six new tests exercise stale_chat_url_replacement, a two-line string comparison. The four-condition guard, the detect_local_chat call, the notice, and the stale_replacement.or(llm) sequencing — the wiring that can regress — have none. stale_chat_url_replacement_none_when_nothing_live_found (chat.rs:702-708) is effectively asserting Option::filter(None).
10. normalize_base_url allocates needlessly — chat.rs:329. Every step yields a sub-slice; returning &str avoids the to_string(). Cosmetic.
Worth calling out as good
The chat_api_key.is_none() && chat_auth_header.is_none() gate (mod.rs:1812-1813) is the right instinct and correctly reasoned — a keyless local swap replacing a credentialed remote gateway would have been a real regression, and it was anticipated rather than found later. Extracting the decision into a pure I/O-free helper is also the right shape; the gap is that the untested part is the part that isn't pure.
Also noted, not asks
Startup latency on the degraded path grows by a full detect_local_chat on top of the probe (bounded, failure-path only, and disclosed in the description). A server dying mid-session still yields a bare ChatError via on_chat_error (mod.rs:1257) with no re-probe — reasonable scope-narrowing, but the failure class is half-covered.
Summary
tui.chat_urlat startup: if it's unreachable, thedash falls back to a freshly-detected local engine instead of silently
building an agent against a dead endpoint.
/detect savecommand to persist the replacement.Root cause
tui.chat_urlsurvives across dash launches once written (e.g. via the in-TUI"use & save" flow). If the server behind it is later stopped or replaced — most
commonly a managed vLLM/Lemonade instance relaunched on a different port — the
startup path passed the stale URL straight into
resolve_llm_config, whosecli_url.or(cfg_url).or(env_url)(llm.rs:76-80) returns it unconditionally;probe_okonly gates theDEFAULT_CHAT_BASE_URLfallback. The chat backend wasthen built against a dead endpoint with no on-screen indication anything had
changed — the user only discovered it from a failed completion request.
Fix
app/chat.rs: two pure, I/O-free helpers.stale_chat_url_replacement— given the configured base URL, whether itsprobe succeeded, and an already-detected live
LlmConfig, returnsSome(live)only when the configured endpoint is unreachable and agenuinely different live endpoint exists.
normalize_base_url— drops a trailing/and/v1so a formatting-onlydifference (
http://h:8000vshttp://h:8000/v1/) is not mistaken for aserver change.
app/mod.rs: wires this into theConfigured-but-unreachable branch of thestartup path in
event_loop(), afterdiscover_configured_chat_model. Areplacement, if found, is substituted via
stale_replacement.or(llm)and aChatTurn::system(...)notice is pushed into the transcript.Credential safety
The swap additionally requires
chat_api_key.is_none() && chat_auth_header.is_none().The replacement is a keyless
detected_llm_config, so a remote gateway URL plusOPENAI_API_KEYthat merely TCP-times-out must never silently fall back to alocal keyless engine and drop the credential. That confines the swap to the true
target: a dead persisted local endpoint.
Precedence preserved
resolve_llm_config's CLI>config>env>probe precedence is not modified. Thesubstitution happens purely at the call site, swapping in a different
LlmConfigafter that function runs. Its existing unit tests —cli_wins_over_every_other_tier,config_wins_when_no_cli,env_url_wins_when_no_cli_or_config,probed_default_used_when_nothing_configured_but_reachable— are unmodified andpass unchanged.
chat_env_urlis a separate, lower-precedence tier and isuntouched.
This PR also corrects the
ResolvedArgs::chat_urldoc comment, which claimedthe field was a "CLI-flag value already merged over config". There is no
--chat-urlflag anywhere inapps/rocm/src/main.rs;apps/rocm/src/dash.rssources the field solely from persisted
tui.chat_url. That is precisely thetier this change revalidates, so the comment directly contradicted the
invariant the fix relies on.
Startup cost on the degraded path
When a configured
chat_urlis unreachable with no key/header,detect_local_chatnow runs after the initial probe, adding a bounded one-time cost (~300 ms probe
plus detection) before the first frame. This is paid only in that specific
failure case; the healthy path is unchanged.
Rebase note
Previously this branch carried a hand-merge of PR #97. #97 has since merged
upstream (
8308450), so the branch has been rebased onto currentmainand theduplicated #97 commits dropped. What remains is a single logical commit
containing only this fix, replayed against
main's current startup flow(
startup_chat_outcome/discover_configured_chat_model).Relates to EAI-7360.
Test Plan
New unit tests (all in
app/chat.rs):stale_chat_url_replacement_none_when_configured_endpoint_reachablestale_chat_url_replacement_none_when_nothing_live_foundstale_chat_url_replacement_none_when_live_matches_configuredstale_chat_url_replacement_swaps_to_a_different_live_endpointstale_chat_url_replacement_ignores_trailing_slash_and_v1_formattingnormalize_base_url_strips_trailing_slash_and_v1Local gates on the rebased head:
cargo clippy --workspace --all-targets -- -D warnings(clean)cargo test --workspace --all-targets(all green;rocm-dash-tuilib:612 passed, 0 failed)
python3 scripts/smoke_local.py(smoke: ok)Not run locally: GPU and Strix Halo E2E lanes (no such hardware on this host) —
those are covered by CI.