chore(release): v0.2.7 — honest pins, visible managed model, physical delete
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
- 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]]
|
[[package]]
|
||||||
name = "cortiq-gateway"
|
name = "cortiq-gateway"
|
||||||
version = "0.2.6"
|
version = "0.2.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cortiq-gateway"
|
name = "cortiq-gateway"
|
||||||
version = "0.2.6"
|
version = "0.2.7"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "Apache-2.0"
|
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 {
|
async fn list_models(State(state): State<SharedState>) -> ApiResult {
|
||||||
let live = state.live();
|
let live = state.live();
|
||||||
let models: Vec<Value> = live
|
let mut models: Vec<Value> = live
|
||||||
.cfg
|
.cfg
|
||||||
.models
|
.models
|
||||||
.iter()
|
.iter()
|
||||||
@@ -440,6 +440,31 @@ async fn list_models(State(state): State<SharedState>) -> ApiResult {
|
|||||||
v
|
v
|
||||||
})
|
})
|
||||||
.collect();
|
.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 }))
|
ok(json!({ "models": models }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,15 +498,86 @@ async fn update_model(
|
|||||||
ok(json!({ "ok": true }))
|
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 {
|
async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
|
||||||
let mut cfg = current_cfg(&state);
|
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();
|
let before = cfg.models.len();
|
||||||
cfg.models.retain(|x| x.id != id);
|
cfg.models.retain(|x| x.id != id);
|
||||||
if cfg.models.len() == before {
|
if cfg.models.len() == before {
|
||||||
return Err(ApiError::not_found(format!("model '{id}' not found")));
|
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)?;
|
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 {
|
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 explicit pin must never silently answer from a different model: if
|
||||||
// an unknown pinned model while the router is off), fall back to the
|
// the pinned id is unknown or was filtered out, fail loudly so the caller
|
||||||
// managed local model so a configured local backend still answers.
|
// 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 {
|
if final_candidates.is_empty() && live.cfg.cmf.manage_server {
|
||||||
for id in live.local_candidates() {
|
for id in live.local_candidates() {
|
||||||
if let Some(prov) = live.registry.get(&id) {
|
if let Some(prov) = live.registry.get(&id) {
|
||||||
|
|||||||
Vendored
+2
@@ -129,6 +129,8 @@ export default {
|
|||||||
"models.form.secretMissing": "not set",
|
"models.form.secretMissing": "not set",
|
||||||
"models.form.secretPlaceholder": "set or replace key (write-only)",
|
"models.form.secretPlaceholder": "set or replace key (write-only)",
|
||||||
"models.deleteConfirm": "Delete model \"{id}\"?",
|
"models.deleteConfirm": "Delete model \"{id}\"?",
|
||||||
|
"models.managed": "managed",
|
||||||
|
"models.managedHint": "Provided by the managed CMF server (Settings → Local models). Configure it there.",
|
||||||
|
|
||||||
// routing
|
// routing
|
||||||
"routing.title": "Routing",
|
"routing.title": "Routing",
|
||||||
|
|||||||
Vendored
+2
@@ -128,6 +128,8 @@ export default {
|
|||||||
"models.form.secretMissing": "не задан",
|
"models.form.secretMissing": "не задан",
|
||||||
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
||||||
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
||||||
|
"models.managed": "управляемая",
|
||||||
|
"models.managedHint": "Предоставляется CMF-сервером (Настройки → Локальные модели). Настраивается там.",
|
||||||
|
|
||||||
// routing
|
// routing
|
||||||
"routing.title": "Роутинг",
|
"routing.title": "Роутинг",
|
||||||
|
|||||||
Vendored
+3
-2
@@ -168,7 +168,8 @@ function row(m, meta, reload) {
|
|||||||
return h(
|
return h(
|
||||||
"tr",
|
"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", {}, m.provider),
|
||||||
h("td", { class: "mono" }, m.model),
|
h("td", { class: "mono" }, m.model),
|
||||||
h("td", {}, h("span", { class: "badge " + (m.cost_tier || "") }, m.cost_tier || "—")),
|
h("td", {}, h("span", { class: "badge " + (m.cost_tier || "") }, m.cost_tier || "—")),
|
||||||
@@ -182,7 +183,7 @@ function row(m, meta, reload) {
|
|||||||
"div",
|
"div",
|
||||||
{ class: "flex", style: "justify-content:flex-end" },
|
{ class: "flex", style: "justify-content:flex-end" },
|
||||||
probeBtn,
|
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(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user