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:
Generated
+1
-1
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "cortiq-gateway"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Vendored
+2
@@ -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",
|
||||
|
||||
Vendored
+2
@@ -128,6 +128,8 @@ export default {
|
||||
"models.form.secretMissing": "не задан",
|
||||
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
||||
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
||||
"models.managed": "управляемая",
|
||||
"models.managedHint": "Предоставляется CMF-сервером (Настройки → Локальные модели). Настраивается там.",
|
||||
|
||||
// routing
|
||||
"routing.title": "Роутинг",
|
||||
|
||||
Vendored
+3
-2
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user