From 0105a43ef5c86604f040414e66f6f4939b685503 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Thu, 9 Jul 2026 16:30:48 +0300 Subject: [PATCH] feat(models): auto-derive id from model name; actionable probe auth errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The model id is now auto-filled from the model name as you type, and if left blank it's derived on save (slug of the last path segment, e.g. "qwen/qwen3.7-max" → "qwen3-7-max") — saving no longer errors on a blank id. - probe/test auth failures (401 / "User not found" / "No auth") now append a hint to check the API key (for OpenRouter: use an inference key, not a provisioning/management key). --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/admin/mod.rs | 16 +++++++++++++++- web/js/i18n/en.js | 1 + web/js/i18n/ru.js | 1 + web/js/views/models.js | 23 ++++++++++++++++++++++- 6 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86ca984..eb723bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cortiq-gateway" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 8c58242..af46ef4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortiq-gateway" -version = "0.2.11" +version = "0.2.12" edition = "2021" rust-version = "1.75" license = "Apache-2.0" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 4bb9f6e..cddf1ee 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -749,7 +749,21 @@ async fn probe_model(State(state): State, Path(id): Path) - let latency_ms = started.elapsed().as_millis() as u64; match res { Ok(_) => ok(json!({ "ok": true, "latency_ms": latency_ms })), - Err(e) => ok(json!({ "ok": false, "latency_ms": latency_ms, "error": e.to_string() })), + Err(e) => { + let msg = e.to_string(); + // Make auth failures actionable — a raw "User not found" (OpenRouter's + // 401) usually means the API key is wrong or the wrong type. + let hint = if msg.contains("401") + || msg.contains("Unauthorized") + || msg.contains("User not found") + || msg.contains("No auth") + { + " — check the API key (for OpenRouter use an inference key, not a provisioning/management key)" + } else { + "" + }; + ok(json!({ "ok": false, "latency_ms": latency_ms, "error": format!("{msg}{hint}") })) + } } } diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 3bd73ee..a712429 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -129,6 +129,7 @@ export default { "models.form.secretMissing": "not set", "models.form.secretPlaceholder": "set or replace key (write-only)", "models.deleteConfirm": "Delete model \"{id}\"?", + "models.form.idOrModel": "Enter an id or a model name", "models.form.apiKey": "API Key", "models.form.apiKeyPlaceholder": "paste your API key", "models.form.apiKeyNotNeeded": "not required (local server)", diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index eed34d9..2549b40 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -128,6 +128,7 @@ export default { "models.form.secretMissing": "не задан", "models.form.secretPlaceholder": "задать или заменить ключ (только запись)", "models.deleteConfirm": "Удалить модель \"{id}\"?", + "models.form.idOrModel": "Укажите id или имя модели", "models.form.apiKey": "API-ключ", "models.form.apiKeyPlaceholder": "вставьте API-ключ", "models.form.apiKeyNotNeeded": "не нужен (локальный сервер)", diff --git a/web/js/views/models.js b/web/js/views/models.js index 9f78039..c1b809b 100644 --- a/web/js/views/models.js +++ b/web/js/views/models.js @@ -26,6 +26,13 @@ const PROVIDER_DEFAULTS = { http: { base: "", keyEnv: "", needsKey: false, tier: "cheap", caps: ["tools"] }, }; +// Derive a clean model id from a model name (last path segment, slugified). +// e.g. "qwen/qwen3.7-max" → "qwen3-7-max". +function slugId(name) { + const base = (name || "").split("/").pop() || ""; + return base.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + function modelForm(meta, existing) { const m = existing || { provider: "openai", kind: "chat", cost_tier: "mid", caps: ["tools"], price_in: 0, price_out: 0 }; const idIn = h("input", { value: m.id || "", placeholder: "my-model", disabled: existing ? true : null }); @@ -70,6 +77,17 @@ function modelForm(meta, existing) { providerSel.addEventListener("change", applyDefaults); if (!existing) applyDefaults(); + // Auto-fill the id from the model name (until the user edits id by hand). + if (!existing) { + modelIn.addEventListener("input", () => { + if (!idIn.value || idIn._auto) { + idIn.value = slugId(modelIn.value); + idIn._auto = true; + } + }); + idIn.addEventListener("input", () => { idIn._auto = false; }); + } + async function testKey() { if (!baseUrlIn.value.trim()) { mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?")); return; } testBtn.disabled = true; @@ -142,8 +160,11 @@ async function openModal(meta, existing, reload) { const { node, getValue } = modelForm(meta, existing); modal(existing ? t("models.form.editTitle") : t("models.form.addTitle"), node, async () => { const { model, secret, keyEnv } = getValue(); + // Forgot the id? Derive it from the model name so saving never errors on a + // blank id. + if (!model.id) model.id = slugId(model.model); if (!model.id) { - toast(t("models.form.id") + "?", "bad"); + toast(t("models.form.idOrModel"), "bad"); return false; } if (!model.api_key_env) delete model.api_key_env; // keep serde field optional