From f8c1bb0914ba3db5a8e3511a8018e88b4d174914 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Wed, 8 Jul 2026 21:14:03 +0300 Subject: [PATCH] =?UTF-8?q?chore(release):=20v0.2.7=20=E2=80=94=20honest?= =?UTF-8?q?=20pins,=20visible=20managed=20model,=20physical=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A pinned playground model that is unavailable now errors clearly instead of silently answering from the managed local model (route_source no longer lies). - The managed CMF model is surfaced in GET /admin/api/models (marked "managed", shown read-only in the UI) so the real running model is visible and pinnable without a hand-maintained [[models]] entry; a fresh install still lists nothing. - Deleting a model now removes its local .cmf physically (file or tensor-dir); deleting the managed model also stops the server and clears the [cmf] config. Routing is stripped of the deleted id so the config still validates. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/admin/mod.rs | 100 ++++++++++++++++++++++++++++++++++++++++- src/pipeline.rs | 18 ++++++-- web/js/i18n/en.js | 2 + web/js/i18n/ru.js | 2 + web/js/views/models.js | 5 ++- 7 files changed, 122 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 976a882..39a2566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cortiq-gateway" -version = "0.2.6" +version = "0.2.7" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 419ab70..d95d106 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortiq-gateway" -version = "0.2.6" +version = "0.2.7" edition = "2021" rust-version = "1.75" license = "Apache-2.0" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index b063871..d4f813e 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -426,7 +426,7 @@ async fn put_config(State(state): State, Json(cfg): Json) - async fn list_models(State(state): State) -> ApiResult { let live = state.live(); - let models: Vec = live + let mut models: Vec = live .cfg .models .iter() @@ -440,6 +440,31 @@ async fn list_models(State(state): State) -> ApiResult { v }) .collect(); + + // The managed local CMF server is registered in the routing registry from + // the [cmf] flags, not from [[models]]. Surface it here (marked managed, so + // the UI shows it read-only) so the real running model is visible and + // pinnable without needing a hand-maintained static pool entry. A fresh + // install with no managed model still returns an empty list. + let cmf = &live.cfg.cmf; + if cmf.manage_server + && !cmf.local_model.trim().is_empty() + && !models.iter().any(|m| m["id"] == json!(cmf.model_id)) + { + models.push(json!({ + "id": cmf.model_id, + "provider": "openai", + "base_url": format!("http://{}:{}/v1", cmf.local_host, cmf.local_port), + "model": "cortiq", + "cost_tier": "local", + "price_in": 0.0, + "price_out": 0.0, + "kind": "chat", + "caps": [], + "key_source": "none", + "managed": true, + })); + } ok(json!({ "models": models })) } @@ -473,15 +498,86 @@ async fn update_model( ok(json!({ "ok": true })) } +/// Remove a `.cmf` path — it may be a plain file or a tensor-dir. Returns whether +/// something was actually removed. +fn remove_cmf_path(path: &std::path::Path) -> bool { + if path.is_dir() { + std::fs::remove_dir_all(path).is_ok() + } else if path.is_file() { + std::fs::remove_file(path).is_ok() + } else { + false + } +} + +/// The local `.cmf` backing a pool model, if it is a local CMF model whose file +/// lives under `models_dir`. Cloud models (real remote base_url) return None. +/// Registered imports store `model` = the file stem under models_dir. +fn cmf_file_for_model( + cmf: &crate::config::CmfCfg, + m: &crate::config::ModelCfg, +) -> Option { + let local = m.base_url == cmf.cortiq_server_url + || m.base_url + .contains(&format!("{}:{}", cmf.local_host, cmf.local_port)); + let stem = m.model.trim(); + if !local || stem.is_empty() || stem.contains('/') || stem.contains("..") { + return None; // not local, or an unsafe/underivable path + } + Some(std::path::Path::new(&cmf.models_dir).join(format!("{stem}.cmf"))) +} + +/// Drop a model id from routing (default + every tier) so `validate()` still +/// passes after the model is gone. +fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) { + if routing.default == id { + routing.default = String::new(); + } + for tt in routing.tiers.values_mut() { + match tt { + crate::config::TierTargets::List(v) => v.retain(|x| x != id), + crate::config::TierTargets::One(s) if s == id => { + *tt = crate::config::TierTargets::List(Vec::new()); + } + crate::config::TierTargets::One(_) => {} + } + } +} + async fn delete_model(State(state): State, Path(id): Path) -> ApiResult { let mut cfg = current_cfg(&state); + + // The managed local model lives in [cmf], not [[models]]. Deleting it stops + // the running server, clears the managed config, and removes its file. + if id == cfg.cmf.model_id + && cfg.cmf.manage_server + && !cfg.cmf.local_model.trim().is_empty() + && !cfg.models.iter().any(|x| x.id == id) + { + let file = cfg.cmf.local_model.clone(); + cfg.cmf.local_model = String::new(); + cfg.cmf.manage_server = false; + strip_id_from_routing(&mut cfg.routing, &id); + state.cmf.stop().await; + state.reload(cfg)?; + let file_removed = remove_cmf_path(std::path::Path::new(&file)); + return ok(json!({ "ok": true, "file_removed": file_removed })); + } + + let victim = cfg.models.iter().find(|x| x.id == id).cloned(); let before = cfg.models.len(); cfg.models.retain(|x| x.id != id); if cfg.models.len() == before { return Err(ApiError::not_found(format!("model '{id}' not found"))); } + strip_id_from_routing(&mut cfg.routing, &id); + let file = victim + .as_ref() + .and_then(|m| cmf_file_for_model(&cfg.cmf, m)); state.reload(cfg)?; - ok(json!({ "ok": true })) + // Physically remove the local .cmf after the config is persisted. + let file_removed = file.map(|p| remove_cmf_path(&p)).unwrap_or(false); + ok(json!({ "ok": true, "file_removed": file_removed })) } async fn probe_model(State(state): State, Path(id): Path) -> ApiResult { diff --git a/src/pipeline.rs b/src/pipeline.rs index 8c9a4b2..9f6139e 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -267,9 +267,21 @@ impl Pipeline { } } - // Local-first safety net: if routing produced no usable candidate (e.g. - // an unknown pinned model while the router is off), fall back to the - // managed local model so a configured local backend still answers. + // An explicit pin must never silently answer from a different model: if + // the pinned id is unknown or was filtered out, fail loudly so the caller + // knows their choice was not honoured (instead of getting a stray reply + // from the managed local model via the safety net below). + if final_candidates.is_empty() { + if let RoutingDirective::Pinned { model_id } = &req.routing { + return Err(GatewayError::UpstreamUnavailable(format!( + "pinned model '{model_id}' is not available — it is not in the pool (deleted?) or lacks a required capability such as tools" + ))); + } + } + + // Local-first safety net: for AUTO/degraded routes only, if routing + // produced no usable candidate, fall back to the managed local model so a + // configured local backend still answers. if final_candidates.is_empty() && live.cfg.cmf.manage_server { for id in live.local_candidates() { if let Some(prov) = live.registry.get(&id) { diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index f68169c..7bfe1b2 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -129,6 +129,8 @@ export default { "models.form.secretMissing": "not set", "models.form.secretPlaceholder": "set or replace key (write-only)", "models.deleteConfirm": "Delete model \"{id}\"?", + "models.managed": "managed", + "models.managedHint": "Provided by the managed CMF server (Settings → Local models). Configure it there.", // routing "routing.title": "Routing", diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 8cfc521..5b250c9 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -128,6 +128,8 @@ export default { "models.form.secretMissing": "не задан", "models.form.secretPlaceholder": "задать или заменить ключ (только запись)", "models.deleteConfirm": "Удалить модель \"{id}\"?", + "models.managed": "управляемая", + "models.managedHint": "Предоставляется CMF-сервером (Настройки → Локальные модели). Настраивается там.", // routing "routing.title": "Роутинг", diff --git a/web/js/views/models.js b/web/js/views/models.js index 9f3ff68..3a964b2 100644 --- a/web/js/views/models.js +++ b/web/js/views/models.js @@ -168,7 +168,8 @@ function row(m, meta, reload) { return h( "tr", {}, - h("td", { class: "mono" }, m.id), + h("td", { class: "mono" }, m.id, + m.managed ? h("span", { class: "badge", style: "margin-left:6px", title: t("models.managedHint") }, t("models.managed")) : null), h("td", {}, m.provider), h("td", { class: "mono" }, m.model), h("td", {}, h("span", { class: "badge " + (m.cost_tier || "") }, m.cost_tier || "—")), @@ -182,7 +183,7 @@ function row(m, meta, reload) { "div", { class: "flex", style: "justify-content:flex-end" }, probeBtn, - h("button", { class: "btn sm ghost", onClick: () => openModal(meta, m, reload) }, t("common.edit")), + m.managed ? null : h("button", { class: "btn sm ghost", onClick: () => openModal(meta, m, reload) }, t("common.edit")), h( "button", {