chore(release): v0.2.7 — honest pins, visible managed model, physical delete

- 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.
This commit is contained in:
infosave2007
2026-07-08 21:14:03 +03:00
parent 0f299b77e6
commit f8c1bb0914
7 changed files with 122 additions and 9 deletions
Generated
+1 -1
View File
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cortiq-gateway"
version = "0.2.6"
version = "0.2.7"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -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"
+98 -2
View File
@@ -426,7 +426,7 @@ async fn put_config(State(state): State<SharedState>, Json(cfg): Json<Config>) -
async fn list_models(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
let models: Vec<Value> = live
let mut models: Vec<Value> = live
.cfg
.models
.iter()
@@ -440,6 +440,31 @@ async fn list_models(State(state): State<SharedState>) -> 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<std::path::PathBuf> {
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<SharedState>, Path(id): Path<String>) -> 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<SharedState>, Path(id): Path<String>) -> ApiResult {
+15 -3
View File
@@ -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) {
+2
View File
@@ -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",
+2
View File
@@ -128,6 +128,8 @@ export default {
"models.form.secretMissing": "не задан",
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
"models.deleteConfirm": "Удалить модель \"{id}\"?",
"models.managed": "управляемая",
"models.managedHint": "Предоставляется CMF-сервером (Настройки → Локальные модели). Настраивается там.",
// routing
"routing.title": "Роутинг",
+3 -2
View File
@@ -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",
{