feat(cmf): integrate CMF as a managed local model server
Run models in the CMF (.cmf) format locally, via the native `cortiq` CLI from crates.io — no Python and no external model server. - cmf_runtime: install/update `cortiq-cli` from crates.io, spawn and health-check `cortiq serve` as a managed local backend. - import: model-import jobs shell out to the native `cortiq convert` (HF repo -> .cmf, quant mapped Q8_2F->q8_2f / Q8_ROW->q8 / Q4->q4); the Python converter is used only for advanced/exotic options. - pipeline: local-first routing — serve from the local CMF model when the router is disabled/unreachable, with a "local only" hard switch. - registry/state: register the local CMF server as an OpenAI provider and track local candidates. - config: data-dir resolution + CmfCfg (auto-install/update, managed server, host/port/model) and relaxed validation for the managed model. - admin + settings UI (en/ru): "Local models (CMF)" settings card.
This commit is contained in:
Vendored
+178
-3
@@ -2,7 +2,7 @@
|
||||
import { h, mount, toast } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { appState } from "../app.js";
|
||||
import { appState, SITE_URL } from "../app.js";
|
||||
|
||||
function field(label, control, hint) {
|
||||
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null);
|
||||
@@ -12,9 +12,45 @@ function check(label, checked) {
|
||||
return { node: h("label", { class: "check" }, cb, label), cb };
|
||||
}
|
||||
|
||||
// Flatten router usage (nested account/usage objects) into readable, wrapping
|
||||
// key: value rows — a raw JSON.stringify dump overflows the card horizontally.
|
||||
function usageRows(obj, prefix) {
|
||||
const rows = [];
|
||||
for (const [k, v] of Object.entries(obj || {})) {
|
||||
const key = prefix ? prefix + "." + k : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
rows.push(...usageRows(v, key));
|
||||
} else {
|
||||
rows.push(
|
||||
h("div", { class: "mono kv" },
|
||||
h("span", { class: "kv-k" }, key + ": "),
|
||||
h("span", { class: "kv-v" }, Array.isArray(v) ? JSON.stringify(v) : String(v)))
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Probe status → i18n key suffix (see /admin/api/router/probe).
|
||||
function probeMsgKey(status) {
|
||||
const map = {
|
||||
ok: "ok",
|
||||
no_key: "noKey",
|
||||
auth: "auth",
|
||||
payment: "payment",
|
||||
quota: "quota",
|
||||
timeout: "timeout",
|
||||
unreachable: "unreachable",
|
||||
};
|
||||
return "router.probe." + (map[status] || "error");
|
||||
}
|
||||
|
||||
// Statuses where the fix is on the site (get / pay for a key).
|
||||
const PROBE_SITE_STATUSES = ["no_key", "auth", "payment", "quota"];
|
||||
|
||||
export async function renderSettings() {
|
||||
const meta = appState.meta || (await api.meta());
|
||||
const s = await api.getSettings();
|
||||
const [s, secretsData] = await Promise.all([api.getSettings(), api.listSecrets()]);
|
||||
const root = h("div");
|
||||
|
||||
const listenIn = h("input", { value: s.listen || "" });
|
||||
@@ -24,6 +60,101 @@ export async function renderSettings() {
|
||||
const rTimeout = h("input", { type: "number", value: s.router?.timeout_ms ?? 800 });
|
||||
const rTax = h("input", { value: s.router?.taxonomy_id || "" });
|
||||
const rVerify = check(t("settings.router.verifyTls"), s.router?.verify_tls);
|
||||
const rEnabled = check(t("settings.router.enabled"), s.router?.enabled !== false);
|
||||
// router key value (write-only; stored in the secret store, like model keys)
|
||||
const rKeySource =
|
||||
(secretsData.secrets || []).find((x) => x.name === (s.router?.api_key_env || "CORTIQ_ROUTER_KEY"))?.source ||
|
||||
"missing";
|
||||
const keyStored = rKeySource === "store" || rKeySource === "env";
|
||||
// When a key is already saved, show a masked "saved" hint instead of an empty
|
||||
// field (an empty field reads as "nothing is set").
|
||||
const rSecret = h("input", {
|
||||
type: "password",
|
||||
placeholder: keyStored ? t("settings.router.keyStoredMask") : t("models.form.secretPlaceholder"),
|
||||
});
|
||||
const rKeyBadge = h(
|
||||
"span",
|
||||
{ class: "badge " + (rKeySource === "store" ? "store" : rKeySource === "env" ? "env" : "bad") },
|
||||
rKeySource === "store"
|
||||
? t("models.form.secretStored")
|
||||
: rKeySource === "env"
|
||||
? t("models.form.secretEnv")
|
||||
: t("models.form.secretMissing")
|
||||
);
|
||||
const rSecretHint = h(
|
||||
"div",
|
||||
{},
|
||||
h(
|
||||
"div",
|
||||
{},
|
||||
rKeyBadge,
|
||||
" · ",
|
||||
t("settings.router.getKey") + " ",
|
||||
h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, "api.allaigate.com")
|
||||
),
|
||||
h("div", { class: "muted", style: "margin-top:4px" }, t("settings.router.secretHelp"))
|
||||
);
|
||||
// The key lives in the secret store under this env name (config default: CORTIQ_ROUTER_KEY).
|
||||
const keyName = () => rKeyEnv.value.trim() || "CORTIQ_ROUTER_KEY";
|
||||
// Persist just the pasted router key (independent of the big Save at page top).
|
||||
async function saveKey() {
|
||||
const v = rSecret.value.trim();
|
||||
if (!v) return false;
|
||||
if (!rKeyEnv.value.trim()) rKeyEnv.value = "CORTIQ_ROUTER_KEY";
|
||||
await api.setSecret(keyName(), v);
|
||||
rSecret.value = "";
|
||||
rSecret.placeholder = t("settings.router.keyStoredMask"); // show "saved", not empty
|
||||
rKeyBadge.textContent = t("models.form.secretStored");
|
||||
rKeyBadge.className = "badge store";
|
||||
return true;
|
||||
}
|
||||
const saveKeyBtn = h("button", { class: "btn sm" }, "✓ " + t("settings.router.saveKey"));
|
||||
saveKeyBtn.addEventListener("click", async () => {
|
||||
saveKeyBtn.disabled = true;
|
||||
try {
|
||||
if (await saveKey()) toast(t("settings.router.keySaved"), "good");
|
||||
else toast(t("settings.router.keyEmpty"), "warn");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
saveKeyBtn.disabled = false;
|
||||
});
|
||||
// "Test router" — real /v1/route call: tells key problems apart from a down router
|
||||
const probeBtn = h("button", { class: "btn" }, t("settings.router.test"));
|
||||
const probeOut = h("div", { class: "hint", style: "margin-top:8px" });
|
||||
probeBtn.addEventListener("click", async () => {
|
||||
probeBtn.disabled = true;
|
||||
mount(probeOut, h("span", { class: "spinner" }));
|
||||
try {
|
||||
// If a key was just pasted but not saved, persist it first so the test uses it.
|
||||
if (rSecret.value.trim()) {
|
||||
try {
|
||||
await saveKey();
|
||||
} catch (_) {
|
||||
/* fall through — probe will report no_key/auth */
|
||||
}
|
||||
}
|
||||
const r = await api.probeRouter();
|
||||
const parts = [t(probeMsgKey(r.status), { ms: r.latency_ms ?? "—" })];
|
||||
if (r.message) parts.push(" — " + r.message);
|
||||
const line = h("span", {}, ...parts);
|
||||
const out = [line];
|
||||
if (PROBE_SITE_STATUSES.includes(r.status)) {
|
||||
out.push(" · ", h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, t("dash.health.getKey") + " ↗"));
|
||||
}
|
||||
if (r.usage && typeof r.usage === "object") {
|
||||
out.push(
|
||||
h("div", { class: "usage-box", style: "margin-top:4px" },
|
||||
h("b", {}, t("router.usage")), ...usageRows(r.usage))
|
||||
);
|
||||
}
|
||||
mount(probeOut, h("div", {}, h("span", { class: "badge " + (r.ok ? "ok" : "bad") }, r.ok ? "OK" : "×"), " ", ...out));
|
||||
toast(t(probeMsgKey(r.status), { ms: r.latency_ms ?? "—" }), r.ok ? "good" : "bad");
|
||||
} catch (e) {
|
||||
mount(probeOut, String(e.message || e));
|
||||
}
|
||||
probeBtn.disabled = false;
|
||||
});
|
||||
// route
|
||||
const strategySel = h("select", {}, ...(meta.text_strategies || []).map((x) => h("option", { value: x, selected: x === s.route?.text_strategy }, x)));
|
||||
const maxChars = h("input", { type: "number", value: s.route?.max_chars ?? 4000 });
|
||||
@@ -54,13 +185,24 @@ export async function renderSettings() {
|
||||
h("option", { value: "" }, "auto"),
|
||||
...embedModels.map((id) => h("option", { value: id, selected: id === s.cache?.embed_model }, id))
|
||||
);
|
||||
// local models (CMF) — run a local .cmf server and/or force local-only routing
|
||||
const cmfLocalOnly = check(t("settings.cmf.localOnly"), s.cmf?.local_only);
|
||||
const cmfManage = check(t("settings.cmf.manage"), s.cmf?.manage_server);
|
||||
const cmfAutoInstall = check(t("settings.cmf.autoInstall"), s.cmf?.auto_install);
|
||||
const cmfAutoUpdate = check(t("settings.cmf.autoUpdate"), s.cmf?.auto_update);
|
||||
const cmfModel = h("input", { value: s.cmf?.local_model || "", placeholder: "models/model.cmf" });
|
||||
const cmfPort = h("input", { type: "number", value: s.cmf?.local_port ?? 8081 });
|
||||
const cmfModelId = h("input", { value: s.cmf?.model_id || "cmf-local" });
|
||||
|
||||
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
// a pasted router key needs an env name to live under — default it
|
||||
if (rSecret.value.trim() && !rKeyEnv.value.trim()) rKeyEnv.value = "CORTIQ_ROUTER_KEY";
|
||||
const patch = {
|
||||
listen: listenIn.value.trim(),
|
||||
router: {
|
||||
enabled: rEnabled.cb.checked,
|
||||
url: rUrl.value.trim(),
|
||||
api_key_env: rKeyEnv.value.trim() || null,
|
||||
timeout_ms: parseInt(rTimeout.value) || 800,
|
||||
@@ -90,6 +232,16 @@ export async function renderSettings() {
|
||||
max_entries: s.cache?.max_entries ?? 1000,
|
||||
embed_model: cacheEmbedSel.value || null,
|
||||
},
|
||||
cmf: {
|
||||
...(s.cmf || {}),
|
||||
local_only: cmfLocalOnly.cb.checked,
|
||||
manage_server: cmfManage.cb.checked,
|
||||
auto_install: cmfAutoInstall.cb.checked,
|
||||
auto_update: cmfAutoUpdate.cb.checked,
|
||||
local_model: cmfModel.value.trim(),
|
||||
local_port: parseInt(cmfPort.value) || 8081,
|
||||
model_id: cmfModelId.value.trim() || "cmf-local",
|
||||
},
|
||||
};
|
||||
// serde: drop null optionals
|
||||
if (!patch.router.api_key_env) delete patch.router.api_key_env;
|
||||
@@ -98,6 +250,13 @@ export async function renderSettings() {
|
||||
if (!patch.cache.embed_model) delete patch.cache.embed_model;
|
||||
try {
|
||||
const r = await api.putSettings(patch);
|
||||
const secretVal = rSecret.value.trim();
|
||||
if (secretVal) {
|
||||
await api.setSecret(rKeyEnv.value.trim(), secretVal);
|
||||
rSecret.value = "";
|
||||
rKeyBadge.textContent = t("models.form.secretStored");
|
||||
rKeyBadge.className = "badge store";
|
||||
}
|
||||
toast(r.needs_restart ? t("settings.needsRestart") : t("toast.saved"), r.needs_restart ? "warn" : "good");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
@@ -150,8 +309,13 @@ export async function renderSettings() {
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.router"))),
|
||||
field(t("settings.router.url"), rUrl),
|
||||
field(t("settings.router.keyEnv"), rKeyEnv),
|
||||
field(t("settings.router.secret"), h("div", { class: "key-row" }, rSecret, saveKeyBtn), rSecretHint),
|
||||
h("div", { class: "row" }, field(t("settings.router.timeout"), rTimeout), field(t("settings.router.taxonomy"), rTax)),
|
||||
rVerify.node
|
||||
rEnabled.node,
|
||||
rVerify.node,
|
||||
h("div", { class: "divider" }),
|
||||
h("div", { class: "flex" }, probeBtn),
|
||||
probeOut
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
@@ -199,6 +363,17 @@ export async function renderSettings() {
|
||||
h("div", { class: "row" }, field(t("settings.cache.threshold"), cacheThresh), field(t("settings.cache.ttl"), cacheTtlIn)),
|
||||
field(t("settings.cache.embed"), cacheEmbedSel),
|
||||
h("div", { class: "hint" }, t("settings.cache.note"))
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.cmf"))),
|
||||
cmfLocalOnly.node,
|
||||
cmfManage.node,
|
||||
h("div", { class: "row" }, cmfAutoInstall.node, cmfAutoUpdate.node),
|
||||
field(t("settings.cmf.model"), cmfModel),
|
||||
h("div", { class: "row" }, field(t("settings.cmf.port"), cmfPort), field(t("settings.cmf.modelId"), cmfModelId)),
|
||||
h("div", { class: "hint" }, t("settings.cmf.note"))
|
||||
)
|
||||
),
|
||||
h(
|
||||
|
||||
Reference in New Issue
Block a user