Gateway upgrade: tag local builds with the pin; pull outside the lock
Build App (Preview) / compute-version (pull_request) Successful in 8s
Secret Scan / scan (push) Successful in 12s
Secret Scan / scan (pull_request) Successful in 9s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / test (pull_request) Successful in 4m18s
Build App (Preview) / build-macos (pull_request) Successful in 3m15s
Build App (Preview) / build-windows (pull_request) Successful in 6m51s
Build App (Preview) / build-linux (pull_request) Successful in 9m31s
Build App (Preview) / prune-previews (pull_request) Successful in 3s

Second-review follow-ups, replacing the label + probe approach:
- Local builds are tagged `triple-c-gateway:<pin>` 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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-02 15:14:33 -08:00
1 parent 00b504063f
commit 03c77c5f55
5 files changed
+215 -339

No files matched your search

+175 -317
View File
@@ -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<String>, registry_id: Option<String>) -> Option<ChosenImage> {
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<Option<ChosenImage>, 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<Option<&ChosenImage>, ()>,
) -> 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<String>,
}
/// 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<ImageFacts>,
registry: Option<ImageFacts>,
) -> Option<ChosenImage> {
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<String>,
}
/// `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<Option<InspectedImage>, 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<HashMap<String, String>> {
static CACHE: OnceLock<std::sync::Mutex<HashMap<String, String>>> = 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<String> {
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<Option<ChosenImage>, 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<GatewayStatus, String> {
// 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<GatewaySta
/// settings reconcile, which must not start anything the user never started.
pub async fn gateway_container_presence() -> 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<String>>) -> bool {
}
/// `(id, state, config fingerprint label)` for the gateway container, if any.
async fn find_gateway_container() -> Result<Option<(String, String, String)>, String> {
/// `(id, state, config fingerprint label, image id)` for the gateway container.
async fn find_gateway_container() -> Result<Option<(String, String, String, Option<String>)>, String> {
let docker = get_docker()?;
let filters: HashMap<String, Vec<String>> = HashMap::from([(
@@ -465,7 +362,9 @@ async fn find_gateway_container() -> Result<Option<(String, String, String)>, 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<Mutex<()>> = 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<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
pub async fn ensure_gateway_running(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
// 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<String>,
) -> Result<GatewayStatus, String> {
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"));
}
@@ -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.
@@ -39,6 +39,7 @@ const status = (over: Partial<GatewayStatus> = {}): 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(<GatewaySettings />);
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(<GatewaySettings />);
expect(await screen.findByRole("button", { name: "Start" })).toBeInTheDocument();
expect(screen.getByText(/Start will pull it/i)).toBeInTheDocument();
});
});
+19 -19
View File
@@ -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 ─────────────────────────────────────────────── */}
<div className="flex items-center gap-3 flex-wrap">
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
{(status?.image_exists || status?.running) && (
{status && (
<Button
variant={status.running ? "danger" : "primary"}
disabled={loading}
+2
View File
@@ -486,6 +486,8 @@ export interface GatewayStatus {
running: boolean;
port: number;
image_exists: boolean;
/** The container runs something other than the pinned image; Stop + Start recreates it. */
image_outdated: boolean;
model_count: number;
/** Presence only — the provider API key never leaves the keychain. */
has_api_key: boolean;