From 03c77c5f55ecffd856ea6fdf613d1a1b4f194777 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 15:14:33 -0800 Subject: [PATCH] Gateway upgrade: tag local builds with the pin; pull outside the lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-review follow-ups, replacing the label + probe approach: - Local builds are tagged `triple-c-gateway:` instead of `latest`, so a build for an older pin simply doesn't match. Drops the version label, the probe container, its cache and version normalisation — and with them the case where a failed probe made a current build look stale. - The pinned image is pulled before taking the gateway lock, so a long pull no longer blocks Stop, shutdown or settings saves; skipped when the start would fail on configuration anyway. - If no pinned image is available, the old container is removed and the error keeps the pull failure, reporting a removal failure alongside it. - Status gains `image_outdated` (container image id != pinned image id). Settings uses it for the label, keeps Stop for any running gateway, and always offers Start, since Start pulls the image. - Reuse `migration::image_id` and one container-removal helper. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/docker/gateway.rs | 492 +++++++----------- app/src-tauri/src/models/gateway_settings.rs | 4 + .../settings/GatewaySettings.test.tsx | 18 +- .../components/settings/GatewaySettings.tsx | 38 +- app/src/lib/types.ts | 2 + 5 files changed, 215 insertions(+), 339 deletions(-) diff --git a/app/src-tauri/src/docker/gateway.rs b/app/src-tauri/src/docker/gateway.rs index 6032a9e..6ec3708 100644 --- a/app/src-tauri/src/docker/gateway.rs +++ b/app/src-tauri/src/docker/gateway.rs @@ -50,7 +50,76 @@ const GATEWAY_CONFIG_VOLUME: &str = "triple-c-gateway-config"; /// see the long comment in `gateway-container/Dockerfile`, and keep the two in /// lockstep. const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.106.6"; -const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest"; + +/// Local builds are tagged with the pin (`triple-c-gateway:v1.106.6`), never a +/// floating tag. A build made for an older pin then simply doesn't match after +/// an app update, instead of quietly winning over the new pinned image the way +/// the old `triple-c-gateway:latest` tag did. Builds tagged `latest` by earlier +/// app versions are no longer used. +const GATEWAY_LOCAL_REPO: &str = "triple-c-gateway"; + +/// The LiteLLM release this app is pinned to — the tag of +/// [`GATEWAY_REGISTRY_IMAGE`], e.g. `v1.106.6`. +fn pinned_litellm_version() -> &'static str { + GATEWAY_REGISTRY_IMAGE + .rsplit_once(':') + .map(|(_, tag)| tag) + .unwrap_or_default() +} + +fn local_image_ref() -> String { + format!("{}:{}", GATEWAY_LOCAL_REPO, pinned_litellm_version()) +} + +/// The image the gateway container should run, and its content id. The id goes +/// into the container fingerprint so that a newly pulled or rebuilt image +/// recreates the container instead of leaving the old one running. +#[derive(Debug, Clone, PartialEq)] +struct ChosenImage { + reference: String, + id: String, +} + +/// Local build first (same precedence as the STT container), then the pinned +/// upstream image. Both references name the pin, so either is current. +fn choose_gateway_image(local_id: Option, registry_id: Option) -> Option { + local_id + .map(|id| ChosenImage { + reference: local_image_ref(), + id, + }) + .or_else(|| { + registry_id.map(|id| ChosenImage { + reference: GATEWAY_REGISTRY_IMAGE.to_string(), + id, + }) + }) +} + +/// `Ok(None)` when neither pinned image is present; other Docker errors are +/// returned so "Docker is unreachable" is not mistaken for "image missing". +async fn resolve_gateway_image() -> Result, String> { + let local_ref = local_image_ref(); + let (local, registry) = tokio::join!( + super::migration::image_id(&local_ref), + super::migration::image_id(GATEWAY_REGISTRY_IMAGE) + ); + Ok(choose_gateway_image(local?, registry?)) +} + +/// Whether an existing gateway container is running something other than the +/// pinned image. Unknown (the image check failed) is reported as not outdated +/// rather than guessed. +fn container_image_outdated( + container_image_id: Option<&str>, + chosen: Result, ()>, +) -> bool { + match (container_image_id, chosen) { + (None, _) | (_, Err(())) => false, + (Some(_), Ok(None)) => true, + (Some(running), Ok(Some(chosen))) => running != chosen.id, + } +} const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile"); const GATEWAY_DEFAULT_CONFIG: &str = include_str!("../../../../gateway-container/config.yaml"); @@ -66,180 +135,6 @@ const GATEWAY_INTERNAL_PORT: u16 = 4000; const CONFIG_FINGERPRINT_LABEL: &str = "triple-c.gateway.config-fingerprint"; -/// Stamped on local builds so a build of an *older* pin can be told apart from -/// a current one. `triple-c-gateway:latest` is a floating tag: without this, a -/// build made by a previous app version would keep winning over the new pin -/// forever, because the local build takes precedence. -const LITELLM_VERSION_LABEL: &str = "triple-c.gateway.litellm-version"; - -/// The LiteLLM release this app is pinned to — the tag of -/// [`GATEWAY_REGISTRY_IMAGE`], e.g. `v1.106.6`. -fn pinned_litellm_version() -> &'static str { - GATEWAY_REGISTRY_IMAGE - .rsplit_once(':') - .map(|(_, tag)| tag) - .unwrap_or_default() -} - -/// `v1.106.6` and `1.106.6` name the same release: the pin is a Docker tag, -/// a probe reports the Python package version. -fn same_litellm_version(a: &str, b: &str) -> bool { - a.trim().trim_start_matches('v') == b.trim().trim_start_matches('v') -} - -/// What we need to know about one locally present gateway image. -#[derive(Debug, Clone, PartialEq)] -struct ImageFacts { - id: String, - /// From the build label, or — for a build that predates the label — from - /// probing the image. `None` only when neither could tell. - litellm_version: Option, -} - -/// The image the gateway container should run, and its content id. The id goes -/// into the container fingerprint so that a newly pulled or rebuilt image -/// recreates the container instead of leaving the old one running. -#[derive(Debug, Clone, PartialEq)] -struct ChosenImage { - reference: &'static str, - id: String, -} - -/// Local build first (same precedence as the STT container), but only a build -/// of the currently pinned LiteLLM — an older or unlabelled build is ignored so -/// the pinned upstream image is used, or the user is asked to pull/build. -fn choose_gateway_image( - local: Option, - registry: Option, -) -> Option { - if let Some(local) = local { - if local - .litellm_version - .as_deref() - .is_some_and(|v| same_litellm_version(v, pinned_litellm_version())) - { - return Some(ChosenImage { - reference: GATEWAY_LOCAL_IMAGE, - id: local.id, - }); - } - log::debug!( - "Ignoring local gateway build {} (LiteLLM {}, app pins {})", - GATEWAY_LOCAL_IMAGE, - local.litellm_version.as_deref().unwrap_or("unknown"), - pinned_litellm_version() - ); - } - registry.map(|r| ChosenImage { - reference: GATEWAY_REGISTRY_IMAGE, - id: r.id, - }) -} - -/// One locally present image: its id and the version its build label claims. -struct InspectedImage { - id: String, - version_label: Option, -} - -/// `Ok(None)` when the image is not present locally; other Docker errors are -/// returned so "Docker is unreachable" is not mistaken for "image missing". -async fn inspect_gateway_image(reference: &str) -> Result, String> { - let docker = get_docker()?; - match docker.inspect_image(reference).await { - Ok(img) => { - // An engine that reports no id still gets a component that moves - // when the image is rebuilt or re-pulled, so the fingerprint does - // not freeze on "". - let id = img - .id - .clone() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| { - format!("{}@{}", reference, img.created.clone().unwrap_or_default()) - }); - Ok(Some(InspectedImage { - id, - version_label: img - .config - .and_then(|c| c.labels) - .and_then(|l| l.get(LITELLM_VERSION_LABEL).cloned()), - })) - } - Err(bollard::errors::Error::DockerResponseServerError { - status_code: 404, .. - }) => Ok(None), - Err(e) => Err(format!("Failed to inspect image {}: {}", reference, e)), - } -} - -/// Successful probes, by image id. An image's contents never change under its -/// id, so a result is good for the life of the process; failures are not -/// cached, so a transient one is retried. -fn probe_cache() -> &'static std::sync::Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())) -} - -/// Ask the image which LiteLLM it contains. Only used for a local build with -/// no version label — one made before builds were labelled. A missing label -/// means "unknown, probe instead", never "stale" (see CLAUDE.md). -async fn probe_litellm_version(reference: &str, id: &str) -> Option { - if let Some(v) = probe_cache().lock().ok().and_then(|c| c.get(id).cloned()) { - return Some(v); - } - let script = - "python -c \"import importlib.metadata as m; print(m.version('litellm'))\""; - match super::migration::run_throwaway(reference, script).await { - Ok(out) if out.exit_code == 0 && !out.stdout.trim().is_empty() => { - let version = out.stdout.trim().to_string(); - if let Ok(mut cache) = probe_cache().lock() { - cache.insert(id.to_string(), version.clone()); - } - Some(version) - } - Ok(out) => { - log::warn!( - "Could not read the LiteLLM version of {} (exit {}): {}", - reference, - out.exit_code, - out.stderr.trim() - ); - None - } - Err(e) => { - log::warn!("Could not probe {} for its LiteLLM version: {}", reference, e); - None - } - } -} - -async fn resolve_gateway_image() -> Result, String> { - let (local, registry) = tokio::join!( - inspect_gateway_image(GATEWAY_LOCAL_IMAGE), - inspect_gateway_image(GATEWAY_REGISTRY_IMAGE) - ); - let local = match local? { - Some(img) => { - let litellm_version = match img.version_label { - Some(v) => Some(v), - None => probe_litellm_version(GATEWAY_LOCAL_IMAGE, &img.id).await, - }; - Some(ImageFacts { - id: img.id, - litellm_version, - }) - } - None => None, - }; - let registry = registry?.map(|img| ImageFacts { - id: img.id, - // The registry reference *is* the pin. - litellm_version: Some(pinned_litellm_version().to_string()), - }); - Ok(choose_gateway_image(local, registry)) -} - /// The default bridge gateway address on a stock native-Linux engine. Only a /// fallback: the real value is read from the `bridge` network's IPAM config. const DEFAULT_BRIDGE_GATEWAY: &str = "172.17.0.1"; @@ -386,26 +281,27 @@ fn sha256_hex(input: &str) -> String { } pub async fn get_gateway_status(settings: &GatewaySettings) -> Result { - // Only a *usable* image counts: an outdated local build reports false so - // the UI offers a pull/build of the pinned version. - let image_exists = match resolve_gateway_image().await { - Ok(chosen) => chosen.is_some(), - Err(e) => { - log::warn!("Gateway image check failed: {}", e); - false - } - }; + let chosen = resolve_gateway_image().await.map_err(|e| { + log::warn!("Gateway image check failed: {}", e); + }); + let image_exists = matches!(chosen, Ok(Some(_))); - let (container_exists, running) = match find_gateway_container().await? { - Some((_, state, _)) => (true, state == "running"), + let container = find_gateway_container().await?; + let (container_exists, running) = match &container { + Some((_, state, _, _)) => (true, state == "running"), None => (false, false), }; + let image_outdated = container_image_outdated( + container.as_ref().and_then(|c| c.3.as_deref()), + chosen.as_ref().map(|c| c.as_ref()).map_err(|_| ()), + ); Ok(GatewayStatus { container_exists, running, port: settings.port, image_exists, + image_outdated, model_count: settings.valid_models().len(), has_api_key: secure::has_gateway_api_key(), base_url: gateway_binding().await.base_url(settings.port), @@ -416,7 +312,7 @@ pub async fn get_gateway_status(settings: &GatewaySettings) -> Result Result<(bool, bool), String> { Ok(match find_gateway_container().await? { - Some((_, state, _)) => (true, state == "running"), + Some((_, state, _, _)) => (true, state == "running"), None => (false, false), }) } @@ -434,7 +330,8 @@ fn is_gateway_container(names: Option<&Vec>) -> bool { } /// `(id, state, config fingerprint label)` for the gateway container, if any. -async fn find_gateway_container() -> Result, String> { +/// `(id, state, config fingerprint label, image id)` for the gateway container. +async fn find_gateway_container() -> Result)>, String> { let docker = get_docker()?; let filters: HashMap> = HashMap::from([( @@ -465,7 +362,9 @@ async fn find_gateway_container() -> Result, St .cloned() .unwrap_or_default(); - return Ok(Some((id, state, fingerprint))); + let image_id = container.image_id.clone().filter(|s| !s.is_empty()); + + return Ok(Some((id, state, fingerprint, image_id))); } Ok(None) @@ -655,7 +554,7 @@ async fn create_gateway_container( ); let config = Config { - image: Some(image.reference.to_string()), + image: Some(image.reference.clone()), // The upstream entrypoint (`docker/prod_entrypoint.sh`) execs // `litellm "$@"`. Passed explicitly so the pulled upstream image and // our locally built one behave identically. @@ -696,11 +595,6 @@ async fn create_gateway_container( /// refuse, because the second caller then observes the first's container, finds /// a matching fingerprint, and returns its status — which is exactly what it /// asked for. -fn gateway_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) -} - async fn remove_gateway_container(docker: &Docker, id: &str, state: &str) -> Result<(), String> { if state == "running" { docker @@ -720,13 +614,40 @@ async fn remove_gateway_container(docker: &Docker, id: &str, state: &str) -> Res .map_err(|e| format!("Failed to remove gateway container: {}", e)) } +fn gateway_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + pub async fn ensure_gateway_running(settings: &GatewaySettings) -> Result { + // Typically the first start after an app update moved the pin: fetch the + // pinned image so the user isn't left on the old LiteLLM. Done *before* + // taking the lock — a multi-hundred-MB download must not hold up Stop or + // shutdown, which take the same lock. Skipped when the start would fail on + // configuration anyway. + let mut pull_error = None; + if !settings.valid_models().is_empty() + && secure::has_gateway_api_key() + && resolve_gateway_image().await?.is_none() + { + log::info!( + "No gateway image for LiteLLM {} — pulling {}", + pinned_litellm_version(), + GATEWAY_REGISTRY_IMAGE + ); + if let Err(e) = super::image::pull_image(GATEWAY_REGISTRY_IMAGE, |_| {}).await { + log::warn!("Gateway image pull failed: {}", e); + pull_error = Some(e); + } + } + let _guard = gateway_lock().lock().await; - ensure_gateway_running_locked(settings).await + ensure_gateway_running_locked(settings, pull_error).await } async fn ensure_gateway_running_locked( settings: &GatewaySettings, + pull_error: Option, ) -> Result { let docker = get_docker()?; @@ -748,57 +669,39 @@ async fn ensure_gateway_running_locked( // Rotation id, not a hash of either secret — see `storage::secure`. let secret_version = secure::get_gateway_secret_version()?.unwrap_or_default(); - let image = match resolve_gateway_image().await? { - Some(image) => image, - // Typically the first start after an app update moved the pin. Pull - // the pinned image rather than leave the user on the old LiteLLM. - None => { - log::info!( - "No gateway image for LiteLLM {} — pulling {}", - pinned_litellm_version(), - GATEWAY_REGISTRY_IMAGE - ); - let pulled = super::image::pull_image(GATEWAY_REGISTRY_IMAGE, |msg| { - log::debug!("gateway pull: {}", msg.trim()) - }) - .await; - match pulled { - Ok(()) => resolve_gateway_image().await?.ok_or_else(|| { - format!("Pulled {} but it is not usable", GATEWAY_REGISTRY_IMAGE) - })?, - Err(e) => { - // Whatever is running is on an old image and, after a - // settings change or key rotation, an old config and old - // keys — never leave it serving. - let stopped = match find_gateway_container().await? { - Some((id, state, _)) => { - remove_gateway_container(&docker, &id, &state).await?; - " The old gateway was stopped." - } - None => "", - }; - return Err(format!( - "The gateway needs LiteLLM {} and pulling it failed: {}.{} \ - Pull or build the image in Settings.", - pinned_litellm_version(), - e, - stopped - )); - } + let Some(image) = resolve_gateway_image().await? else { + let mut message = format!( + "The gateway needs LiteLLM {} and {}. Pull or build the image in Settings.", + pinned_litellm_version(), + match &pull_error { + Some(e) => format!("pulling it failed: {}", e), + None => "that image is not available locally".to_string(), + } + ); + // Neither pinned image exists, so any existing container runs an old + // LiteLLM — and, after a settings change or key rotation, an old + // config and old keys. Never leave it serving. + if let Some((id, state, _, _)) = find_gateway_container().await? { + match remove_gateway_container(&docker, &id, &state).await { + Ok(()) => message.push_str(" The out-of-date gateway container was removed."), + Err(e) => message.push_str(&format!( + " Removing the out-of-date gateway container also failed: {}", + e + )), } } + return Err(message); }; - // The image id is part of the fingerprint: after an app update moves the - // pin (and the new image is pulled or built), the old container no longer - // matches and is recreated on the new image. + // The image id is part of the fingerprint: once the new pinned image is + // present, the old container no longer matches and is recreated on it. let fingerprint = gateway_fingerprint( &config_shape(settings, &binding), &secret_version, &image.id, ); - if let Some((id, state, existing_fingerprint)) = find_gateway_container().await? { + if let Some((id, state, existing_fingerprint, _)) = find_gateway_container().await? { if existing_fingerprint == fingerprint { if state == "running" { return get_gateway_status(settings).await; @@ -811,7 +714,7 @@ async fn ensure_gateway_running_locked( } // Config, a secret or the image changed — recreate so the new config - // is uploaded and the new image is used. + // is uploaded and the pinned image is used. remove_gateway_container(&docker, &id, &state).await?; } @@ -822,15 +725,10 @@ async fn ensure_gateway_running_locked( if let Err(e) = upload_config(&id, &rendered).await { // Don't leave a half-configured container behind for the next run to // mistake for a good one. - let _ = docker - .remove_container( - &id, - Some(RemoveContainerOptions { - force: true, - ..Default::default() - }), - ) - .await; + // Never started, so "created" skips the stop. + if let Err(cleanup) = remove_gateway_container(&docker, &id, "created").await { + log::warn!("{}", cleanup); + } return Err(e); } @@ -859,7 +757,7 @@ pub async fn stop_gateway_container() -> Result<(), String> { let _guard = gateway_lock().lock().await; let docker = get_docker()?; - if let Some((id, state, _)) = find_gateway_container().await? { + if let Some((id, state, _, _)) = find_gateway_container().await? { if state == "running" { docker .stop_container( @@ -915,11 +813,9 @@ where let tar_bytes = create_gateway_build_context() .map_err(|e| format!("Failed to create gateway build context: {}", e))?; + let tag = local_image_ref(); let options = BuildImageOptions { - t: GATEWAY_LOCAL_IMAGE, - // The Dockerfile's FROM is held to the same pin by a test, so the - // label is truthful. See `LITELLM_VERSION_LABEL`. - labels: HashMap::from([(LITELLM_VERSION_LABEL, pinned_litellm_version())]), + t: tag.as_str(), rm: true, forcerm: true, ..Default::default() @@ -1001,26 +897,24 @@ mod tests { } } - fn facts(id: &str, version: Option<&str>) -> ImageFacts { - ImageFacts { + fn chosen(reference: &str, id: &str) -> ChosenImage { + ChosenImage { + reference: reference.to_string(), id: id.to_string(), - litellm_version: version.map(str::to_string), } } #[test] - fn pinned_version_is_the_registry_tag() { + fn local_builds_are_tagged_with_the_pin() { let v = pinned_litellm_version(); - assert!( - v.starts_with('v') && !v.contains(['/', '@', ':']), - "bad pin tag {v:?}" - ); + assert!(v.starts_with('v') && !v.contains(['/', '@', ':']), "bad pin tag {v:?}"); + assert_eq!(local_image_ref(), format!("triple-c-gateway:{}", v)); } #[test] fn dockerfile_from_matches_the_registry_pin() { - // The local-build label claims the pinned version; that is only true - // while the Dockerfile builds FROM the same tag. + // Local builds are tagged with the pin; that is only truthful while the + // Dockerfile builds FROM the same tag. let from = GATEWAY_DOCKERFILE .lines() .find(|l| l.starts_with("FROM ")) @@ -1034,70 +928,34 @@ mod tests { } #[test] - fn current_local_build_wins() { - let chosen = choose_gateway_image( - Some(facts("sha256:local", Some(pinned_litellm_version()))), - Some(facts("sha256:registry", None)), + fn local_build_wins_then_registry_then_nothing() { + assert_eq!( + choose_gateway_image(Some("sha256:local".into()), Some("sha256:reg".into())), + Some(chosen(&local_image_ref(), "sha256:local")) ); assert_eq!( - chosen, - Some(ChosenImage { - reference: GATEWAY_LOCAL_IMAGE, - id: "sha256:local".into() - }) - ); - } - - #[test] - fn outdated_or_unlabelled_local_build_falls_back_to_registry() { - for version in [Some("v1.82.6"), None] { - let chosen = choose_gateway_image( - Some(facts("sha256:local", version)), - Some(facts("sha256:registry", None)), - ); - assert_eq!( - chosen, - Some(ChosenImage { - reference: GATEWAY_REGISTRY_IMAGE, - id: "sha256:registry".into() - }), - "local build labelled {:?}", - version - ); - } - } - - #[test] - fn tag_and_package_versions_compare_equal() { - assert!(same_litellm_version("v1.106.6", "1.106.6")); - assert!(same_litellm_version("1.106.6\n", "v1.106.6")); - assert!(!same_litellm_version("1.106.60", "v1.106.6")); - } - - #[test] - fn probed_current_local_build_still_wins() { - // A pre-label build of the current pin, as reported by the probe. - let chosen = choose_gateway_image( - Some(facts("sha256:local", Some("1.106.6"))), - Some(facts("sha256:registry", Some(pinned_litellm_version()))), - ); - assert_eq!(chosen.map(|c| c.reference), Some(GATEWAY_LOCAL_IMAGE)); - } - - #[test] - fn outdated_local_build_alone_is_not_usable() { - assert_eq!( - choose_gateway_image(Some(facts("sha256:old", Some("v1.82.6"))), None), - None + choose_gateway_image(None, Some("sha256:reg".into())), + Some(chosen(GATEWAY_REGISTRY_IMAGE, "sha256:reg")) ); assert_eq!(choose_gateway_image(None, None), None); } + #[test] + fn outdated_means_the_container_runs_another_image() { + let pinned = chosen(GATEWAY_REGISTRY_IMAGE, "sha256:new"); + assert!(!container_image_outdated(Some("sha256:new"), Ok(Some(&pinned)))); + assert!(container_image_outdated(Some("sha256:old"), Ok(Some(&pinned)))); + // No pinned image at all: whatever runs is not it. + assert!(container_image_outdated(Some("sha256:old"), Ok(None))); + // No container, or the check failed: don't claim it. + assert!(!container_image_outdated(None, Ok(Some(&pinned)))); + assert!(!container_image_outdated(Some("sha256:old"), Err(()))); + } + #[test] fn new_image_changes_the_fingerprint() { let a = gateway_fingerprint("shape", "1", "sha256:old"); - let b = gateway_fingerprint("shape", "1", "sha256:new"); - assert_ne!(a, b); + assert_ne!(a, gateway_fingerprint("shape", "1", "sha256:new")); assert_eq!(a, gateway_fingerprint("shape", "1", "sha256:old")); } diff --git a/app/src-tauri/src/models/gateway_settings.rs b/app/src-tauri/src/models/gateway_settings.rs index bd1a257..0cae244 100644 --- a/app/src-tauri/src/models/gateway_settings.rs +++ b/app/src-tauri/src/models/gateway_settings.rs @@ -89,6 +89,10 @@ pub struct GatewayStatus { pub running: bool, pub port: u16, pub image_exists: bool, + /// The gateway container exists but runs something other than the pinned + /// image (e.g. after an app update moved the pin). Stop and Start + /// recreates it on the pinned image. + pub image_outdated: bool, /// Number of fully-specified models in the current settings. pub model_count: usize, /// Whether a provider API key is present in the keychain. diff --git a/app/src/components/settings/GatewaySettings.test.tsx b/app/src/components/settings/GatewaySettings.test.tsx index 2391340..66da123 100644 --- a/app/src/components/settings/GatewaySettings.test.tsx +++ b/app/src/components/settings/GatewaySettings.test.tsx @@ -39,6 +39,7 @@ const status = (over: Partial = {}): GatewayStatus => ({ running: true, port: 4000, image_exists: true, + image_outdated: false, model_count: 0, has_api_key: false, base_url: "http://host.docker.internal:4000", @@ -102,15 +103,26 @@ describe("GatewaySettings", () => { ); await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2)); }); - it("can still stop a running gateway whose image is out of date", async () => { + it("can stop a gateway running an outdated image, and says so", async () => { appSettings = settingsWithGateway(true); - getGatewayStatus.mockResolvedValue(status({ image_exists: false })); + getGatewayStatus.mockResolvedValue(status({ image_outdated: true })); render(); const stop = await screen.findByRole("button", { name: "Stop" }); - expect(screen.getByText(/image out of date/i)).toBeInTheDocument(); + expect(screen.getByText(/outdated LiteLLM/i)).toBeInTheDocument(); fireEvent.click(stop); await waitFor(() => expect(stopGateway).toHaveBeenCalledTimes(1)); }); + + it("offers Start without an image, since Start pulls it", async () => { + appSettings = settingsWithGateway(true); + getGatewayStatus.mockResolvedValue( + status({ running: false, container_exists: false, image_exists: false }), + ); + render(); + + expect(await screen.findByRole("button", { name: "Start" })).toBeInTheDocument(); + expect(screen.getByText(/Start will pull it/i)).toBeInTheDocument(); + }); }); diff --git a/app/src/components/settings/GatewaySettings.tsx b/app/src/components/settings/GatewaySettings.tsx index 330028f..46fda82 100644 --- a/app/src/components/settings/GatewaySettings.tsx +++ b/app/src/components/settings/GatewaySettings.tsx @@ -178,29 +178,29 @@ export default function GatewaySettings() { setTimeout(() => setCopied(null), 2000); }; - // A running container is reported (and stoppable) even when its image is no - // longer the pinned one — e.g. right after an app update moved the pin. + // A running container is shown (and stoppable) whatever its image. Start + // pulls the pinned image itself when it is missing, so it is always offered. const tone: StatusTone = status?.running - ? healthy === false + ? healthy === false || status.image_outdated ? "busy" : "running" - : !status?.image_exists - ? "off" - : status.container_exists - ? "stopped" - : "off"; + : status?.container_exists + ? "stopped" + : "off"; - const statusLabel = status?.running - ? !status.image_exists - ? `Running on port ${status.port} (image out of date)` - : healthy === false + const statusLabel = !status + ? "Checking…" + : status.running + ? healthy === false ? "Starting…" - : `Running on port ${status.port}` - : !status?.image_exists - ? "No image" - : status.container_exists - ? "Stopped" - : "Image ready"; + : status.image_outdated + ? `Running on port ${status.port} — outdated LiteLLM, Stop and Start to update` + : `Running on port ${status.port}` + : !status.image_exists + ? "No image — Start will pull it" + : status.container_exists + ? "Stopped" + : "Image ready"; // Rendered in whichever branch is live — only one of them ever mounts. const errorLine = error ? ( @@ -258,7 +258,7 @@ export default function GatewaySettings() { {/* ── Container ─────────────────────────────────────────────── */}
- {(status?.image_exists || status?.running) && ( + {status && (