Fifteen changes aimed at the first day of real use: - Import and media jobs persist to <data>/jobs-*.json and reload on start (running ones settle as errors); the Register button no longer vanishes because the gateway restarted. - Docker ships the cortiq binary (built in the builder stage) — local .cmf chat models and image/video generation now work in containers, where auto-install never could (no cargo in the runtime image). libvulkan1 added for optional GPU offload. - Open admin console on 0.0.0.0 without a token now warns in the log and shows a dismissible banner — the VPS footgun is at least labeled. - "Restart gateway" button in Settings: stops managed model servers, spawns a fresh copy (bind retried while the port frees), Docker relies on the container restart policy instead. - Dashboard "System" panel: CPU sparkline + load, RAM (with the gateway's own RSS), free disk at the models dir — sampled by sysinfo every 2 s. - Featured model cards warn "not enough disk" / "may not fit in RAM" before the download, from the same live snapshot. - Log viewer in the sidebar: a 500-line in-memory ring fed by a second ANSI-free tracing layer; filter, level, pause, copy. - Update check: crates.io polled twice a day, the console shows an "update available" notice. - Media generations queue up (with position shown) instead of being rejected; progress gained an honest ETA from measured throughput. - Generation gallery under the image/video tabs: lazy PPM thumbnails, click to reopen with seed/steps, delete. - Playground chat is a real dialogue now: history is resent, bubbles, Clear, Cmd/Ctrl+Enter. - "Connect your app" snippets (curl / Python / JS) on the keys page, with the real key inlined right after creation. - One-click benchmark on the Models page via `cortiq bench --json`. - Config export now downloads the actual gateway.toml (comments intact); import accepts TOML (validated, hot-reloaded) and the legacy JSON. - Streaming token counts are exact when the model server provides usage (cortiq-server patch, shipped separately); the gateway fallback now counts content chunks (1/token on cortiq) instead of chars/4. Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
434 lines
18 KiB
JavaScript
Vendored
434 lines
18 KiB
JavaScript
Vendored
// Models — pool table with add/edit modal, probe, and per-model secret entry.
|
|
import { h, mount, modal, toast, confirmDialog, money, opt } from "../ui.js";
|
|
import { t } from "../i18n.js";
|
|
import { api } from "../api.js";
|
|
import { appState } from "../app.js";
|
|
|
|
function field(label, control, hint) {
|
|
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null);
|
|
}
|
|
|
|
function keyBadge(src) {
|
|
const map = { store: "store", env: "env", missing: "bad", none: "" };
|
|
return h("span", { class: "badge " + (map[src] ?? "") }, src);
|
|
}
|
|
|
|
// Verified per-provider defaults (base URL, key env, whether a key is needed).
|
|
const PROVIDER_DEFAULTS = {
|
|
openai: { base: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", needsKey: true, tier: "mid", caps: ["tools", "vision"] },
|
|
anthropic: { base: "https://api.anthropic.com", keyEnv: "ANTHROPIC_API_KEY", needsKey: true, tier: "expensive", caps: ["tools", "vision"] },
|
|
openrouter: { base: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY", needsKey: true, tier: "mid", caps: ["tools", "vision"] },
|
|
lmstudio: { base: "http://localhost:1234/v1", keyEnv: "", needsKey: false, tier: "local", caps: ["tools", "vision"] },
|
|
ollama: { base: "http://localhost:11434/v1", keyEnv: "", needsKey: false, tier: "local", caps: ["tools"] },
|
|
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 });
|
|
const providerSel = h("select", {}, ...(meta.providers || ["openai"]).map((p) => opt(p, p, p === m.provider)));
|
|
const baseUrlIn = h("input", { value: m.base_url || "", placeholder: "https://api.openai.com/v1" });
|
|
|
|
// API key — typed directly, stored securely as a secret under a provider env name.
|
|
const keyIn = h("input", { type: "password", placeholder: t("models.form.apiKeyPlaceholder"), autocomplete: "new-password" });
|
|
const testBtn = h("button", { class: "btn small", type: "button" }, t("models.form.testKey"));
|
|
const keyStatus = h("span", { class: "small", style: "margin-left:8px" });
|
|
|
|
// Model — below the key (some providers list models from the key); searchable datalist.
|
|
const dlId = "model-opts-" + (m.id || "new");
|
|
const dl = h("datalist", { id: dlId });
|
|
if (m.model) dl.appendChild(h("option", { value: m.model }));
|
|
const modelIn = h("input", { value: m.model || "", placeholder: t("models.form.modelPlaceholder"), list: dlId, autocomplete: "off" });
|
|
|
|
const kindSel = h("select", {}, ...(meta.kinds || ["chat"]).map((k) => opt(k, k, k === m.kind)));
|
|
const tierSel = h("select", {}, ...(meta.cost_tiers || ["cheap"]).map((c) => opt(c, c, c === m.cost_tier)));
|
|
const priceInIn = h("input", { type: "number", step: "0.01", value: m.price_in ?? 0 });
|
|
const priceOutIn = h("input", { type: "number", step: "0.01", value: m.price_out ?? 0 });
|
|
const CAP_EMOJIS = {
|
|
tools: "🔧 tools",
|
|
vision: "👁️ vision",
|
|
audio: "🎙️ audio",
|
|
reasoning: "🧠 reasoning",
|
|
json: "📄 json",
|
|
caching: "⚡ caching"
|
|
};
|
|
|
|
const capInputs = (meta.caps || Object.keys(CAP_EMOJIS)).map((c) => {
|
|
const cb = h("input", { type: "checkbox", checked: (m.caps || []).includes(c) ? true : null });
|
|
return { c, cb };
|
|
});
|
|
|
|
const envFor = () =>
|
|
(existing && existing.api_key_env) ||
|
|
(PROVIDER_DEFAULTS[providerSel.value] || {}).keyEnv ||
|
|
providerSel.value.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_API_KEY";
|
|
|
|
// Fill in a provider's verified defaults when it is selected.
|
|
function applyDefaults() {
|
|
const d = PROVIDER_DEFAULTS[providerSel.value];
|
|
if (!d) return;
|
|
baseUrlIn.value = d.base;
|
|
tierSel.value = d.tier;
|
|
capInputs.forEach(({ c, cb }) => { cb.checked = d.caps.includes(c); });
|
|
keyIn.placeholder = d.needsKey ? t("models.form.apiKeyPlaceholder") : t("models.form.apiKeyNotNeeded");
|
|
mount(keyStatus, "");
|
|
// Automatically pre-fetch models (especially useful for OpenRouter which doesn't need a key)
|
|
testKey(true);
|
|
}
|
|
providerSel.addEventListener("change", applyDefaults);
|
|
if (!existing) applyDefaults();
|
|
|
|
let cachedModels = [];
|
|
|
|
const autofillData = () => {
|
|
console.log("autofillData triggered. modelIn.value:", modelIn.value);
|
|
console.log("cachedModels length:", cachedModels.length);
|
|
if (!existing && (!idIn.value || idIn._auto)) {
|
|
idIn.value = slugId(modelIn.value);
|
|
idIn._auto = true;
|
|
console.log("auto-filled idIn:", idIn.value);
|
|
}
|
|
const found = cachedModels.find((m) => (m.id || m) === modelIn.value);
|
|
console.log("found model:", found);
|
|
if (found) {
|
|
if (found.price_in !== undefined) {
|
|
priceInIn.value = parseFloat(Number(found.price_in).toFixed(4));
|
|
priceOutIn.value = parseFloat(Number(found.price_out).toFixed(4));
|
|
console.log("Updated prices:", priceInIn.value, priceOutIn.value);
|
|
}
|
|
if (found.caps && Array.isArray(found.caps)) {
|
|
capInputs.forEach(({ c, cb }) => {
|
|
cb.checked = found.caps.includes(c);
|
|
});
|
|
console.log("Updated caps:", found.caps);
|
|
}
|
|
}
|
|
};
|
|
modelIn.addEventListener("input", autofillData);
|
|
modelIn.addEventListener("change", autofillData);
|
|
|
|
if (!existing) {
|
|
idIn.addEventListener("input", () => { idIn._auto = false; });
|
|
} else {
|
|
console.log("Editing existing model, fetching testKey(true)");
|
|
testKey(true);
|
|
}
|
|
|
|
async function testKey(silent = false) {
|
|
if (!baseUrlIn.value.trim()) {
|
|
if (!silent) mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?"));
|
|
return;
|
|
}
|
|
testBtn.disabled = true;
|
|
if (!silent) mount(keyStatus, t("models.form.testing"));
|
|
try {
|
|
const r = await api.providerModels({
|
|
provider: providerSel.value,
|
|
base_url: baseUrlIn.value.trim(),
|
|
api_key: keyIn.value.trim() || null,
|
|
api_key_env: envFor(),
|
|
});
|
|
if (r.ok) {
|
|
cachedModels = r.models || [];
|
|
dl.replaceChildren(...cachedModels.map((mm) => h("option", { value: mm.id || mm })));
|
|
|
|
let msg = "✓ " + t("models.form.foundModels", { n: r.count });
|
|
if (r.balance !== undefined) {
|
|
msg += ` (Balance: $${r.balance.toFixed(4)})`;
|
|
}
|
|
mount(keyStatus, h("span", { style: "color:var(--good)" }, msg));
|
|
if (!silent) modelIn.focus();
|
|
} else {
|
|
if (!silent) mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + (r.error || t("common.error"))));
|
|
}
|
|
} catch (e) {
|
|
if (!silent) mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + String(e.message || e)));
|
|
}
|
|
testBtn.disabled = false;
|
|
}
|
|
testBtn.addEventListener("click", () => testKey(false));
|
|
keyIn.addEventListener("change", () => testKey(true));
|
|
|
|
const keyHint =
|
|
existing && existing.key_source === "store" ? t("models.form.keyStored")
|
|
: existing && existing.key_source === "env" ? t("models.form.keyFromEnv")
|
|
: t("models.form.apiKeyHint");
|
|
|
|
// ⚙ Generation & Thinking Parameters
|
|
const tempIn = h("input", { type: "number", step: "0.05", min: "0", max: "2.0", placeholder: "0.7", value: m.temperature ?? "" });
|
|
const topPIn = h("input", { type: "number", step: "0.05", min: "0", max: "1.0", placeholder: "0.9", value: m.top_p ?? "" });
|
|
const maxTokIn = h("input", { type: "number", min: "1", placeholder: "2048", value: m.max_tokens ?? "" });
|
|
const thinkIn = h("input", { type: "number", min: "0", placeholder: "1024", value: m.think_budget ?? "" });
|
|
const o1Sel = h("select", {},
|
|
h("option", { value: "" }, t("import.adv.auto")),
|
|
h("option", { value: "all" }, "all — O(1) Attention"),
|
|
h("option", { value: "deep" }, "deep — O(1) Deep"),
|
|
h("option", { value: "off" }, "off — Standard"));
|
|
if (m.o1) o1Sel.value = m.o1;
|
|
const skipMtpCb = h("input", { type: "checkbox", checked: m.skip_mtp ? true : null });
|
|
const sysPromptIn = h("textarea", { rows: 2, placeholder: t("models.form.systemPromptPlaceholder"), value: m.system_prompt || "" });
|
|
|
|
const advParams = h(
|
|
"details",
|
|
{ class: "adv-params", style: "margin-top:12px;padding:10px;border:1px solid var(--border);border-radius:6px;" },
|
|
h("summary", { style: "font-weight:600;cursor:pointer;" }, "⚙ " + t("models.form.paramsTitle")),
|
|
h("div", { class: "row", style: "margin-top:8px" },
|
|
field(t("models.form.temperature"), tempIn, t("models.form.temperatureHint")),
|
|
field(t("models.form.topP"), topPIn, t("models.form.topPHint"))),
|
|
h("div", { class: "row" },
|
|
field(t("models.form.maxTokens"), maxTokIn, t("models.form.maxTokensHint")),
|
|
field(t("models.form.thinkBudget"), thinkIn, t("models.form.thinkBudgetHint"))),
|
|
h("div", { class: "row" },
|
|
field("O(1) Attention", o1Sel, t("import.adv.o1Hint")),
|
|
field("MTP", h("label", { class: "check" }, skipMtpCb, t("import.adv.skipMtp")))),
|
|
field(t("models.form.systemPrompt"), sysPromptIn, t("models.form.systemPromptHint"))
|
|
);
|
|
|
|
const node = h(
|
|
"div",
|
|
{},
|
|
h("div", { class: "row" }, field(t("models.form.id"), idIn, existing ? null : t("models.form.idHint")), field(t("models.form.provider"), providerSel)),
|
|
field(t("models.form.baseUrl"), baseUrlIn),
|
|
field(t("models.form.apiKey"), h("div", { class: "flex wrap", style: "gap:8px;align-items:center" }, keyIn, testBtn, keyStatus), keyHint),
|
|
field(t("models.form.model"), modelIn, t("models.form.modelHint")),
|
|
dl,
|
|
h("div", { class: "row" }, field(t("models.form.kind"), kindSel), field(t("models.form.costTier"), tierSel)),
|
|
h("div", { class: "row" }, field(t("models.form.priceIn"), priceInIn), field(t("models.form.priceOut"), priceOutIn)),
|
|
field(t("models.form.caps"), h("div", { class: "flex wrap", style: "gap:10px" }, ...capInputs.map(({ c, cb }) => h("label", { class: "check", style: "margin:0" }, cb, CAP_EMOJIS[c] || c)))),
|
|
advParams
|
|
);
|
|
|
|
const getValue = () => {
|
|
const typedKey = keyIn.value.trim();
|
|
const hasKey = !!typedKey || !!(existing && existing.api_key_env);
|
|
const keyEnv = envFor();
|
|
return {
|
|
model: {
|
|
id: existing ? m.id : idIn.value.trim(),
|
|
provider: providerSel.value,
|
|
base_url: baseUrlIn.value.trim(),
|
|
model: modelIn.value.trim(),
|
|
kind: kindSel.value,
|
|
cost_tier: tierSel.value,
|
|
price_in: parseFloat(priceInIn.value) || 0,
|
|
price_out: parseFloat(priceOutIn.value) || 0,
|
|
api_key_env: hasKey ? keyEnv : null,
|
|
caps: capInputs.filter(({ cb }) => cb.checked).map(({ c }) => c),
|
|
temperature: tempIn.value !== "" ? parseFloat(tempIn.value) : null,
|
|
top_p: topPIn.value !== "" ? parseFloat(topPIn.value) : null,
|
|
max_tokens: maxTokIn.value !== "" ? parseInt(maxTokIn.value) : null,
|
|
think_budget: thinkIn.value !== "" ? parseInt(thinkIn.value) : null,
|
|
o1: o1Sel.value || null,
|
|
skip_mtp: skipMtpCb.checked,
|
|
system_prompt: sysPromptIn.value.trim() || null,
|
|
},
|
|
secret: typedKey,
|
|
keyEnv,
|
|
};
|
|
};
|
|
|
|
return { node, getValue };
|
|
}
|
|
|
|
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.idOrModel"), "bad");
|
|
return false;
|
|
}
|
|
if (!model.api_key_env) delete model.api_key_env; // keep serde field optional
|
|
try {
|
|
if (existing) await api.updateModel(existing.id, model);
|
|
else await api.createModel(model);
|
|
if (secret && keyEnv) await api.setSecret(keyEnv, secret);
|
|
toast(t("toast.saved"), "good");
|
|
reload();
|
|
} catch (e) {
|
|
toast(String(e.message || e), "bad");
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function renderModels() {
|
|
const meta = appState.meta || (await api.meta());
|
|
const root = h("div");
|
|
|
|
async function reload() {
|
|
const data = await api.listModels();
|
|
const models = data.models || [];
|
|
mount(
|
|
root,
|
|
h(
|
|
"div",
|
|
{ class: "page-head" },
|
|
h(
|
|
"div",
|
|
{ class: "flex" },
|
|
h("div", { class: "grow" }, h("h2", {}, t("models.title")), h("p", {}, t("models.subtitle"))),
|
|
h("button", { class: "btn primary", onClick: () => openModal(meta, null, reload) }, "+ " + t("models.add"))
|
|
)
|
|
),
|
|
models.length === 0
|
|
? h("div", { class: "card" }, h("div", { class: "empty" }, t("models.empty")))
|
|
: h(
|
|
"div",
|
|
{ class: "card" },
|
|
h(
|
|
"div",
|
|
{ class: "table-wrap" },
|
|
h(
|
|
"table",
|
|
{},
|
|
h(
|
|
"thead",
|
|
{},
|
|
h(
|
|
"tr",
|
|
{},
|
|
h("th", {}, t("models.col.id")),
|
|
h("th", {}, t("models.col.provider")),
|
|
h("th", {}, t("models.col.model")),
|
|
h("th", {}, t("models.col.tier")),
|
|
h("th", {}, t("models.col.key")),
|
|
h("th", {}, t("models.col.caps")),
|
|
h("th", {}, t("models.col.price")),
|
|
h("th", { class: "right-align" }, t("common.actions"))
|
|
)
|
|
),
|
|
h(
|
|
"tbody",
|
|
{},
|
|
...models.map((m) => row(m, meta, reload))
|
|
)
|
|
)
|
|
)
|
|
),
|
|
benchCard()
|
|
);
|
|
}
|
|
|
|
// ── one-click benchmark of local .cmf files (cortiq bench --json) ──
|
|
function benchCard() {
|
|
const host = h("div", { class: "card" }, h("div", { class: "card-title" }, "⏱ " + t("bench.title")),
|
|
h("div", { class: "muted small", style: "margin-bottom:8px" }, t("bench.hint")),
|
|
h("div", { class: "empty" }, t("common.loading")));
|
|
let timer = null;
|
|
async function paint() {
|
|
let files = [];
|
|
let runs = {};
|
|
try {
|
|
files = (await api.mediaModels()).models || [];
|
|
for (const r of (await api.benchList()).runs || []) runs[r.model] = r;
|
|
} catch {
|
|
return;
|
|
}
|
|
const chat = files.filter((m) => m.modality === "chat");
|
|
const anyRunning = Object.values(runs).some((r) => r.state === "running");
|
|
const rows = chat.map((m) => {
|
|
const r = runs[m.name];
|
|
const go = h("button", { class: "btn sm ghost", disabled: anyRunning ? true : null }, "⏱ " + t("bench.run"));
|
|
go.addEventListener("click", async () => {
|
|
try {
|
|
await api.benchStart(m.name);
|
|
paint();
|
|
if (!timer) timer = setInterval(() => {
|
|
if (!host.isConnected) { clearInterval(timer); return; }
|
|
paint();
|
|
}, 3000);
|
|
} catch (e) {
|
|
toast(String(e.message || e), "bad");
|
|
}
|
|
});
|
|
let result = h("span", { class: "muted" }, "—");
|
|
if (r?.state === "running") result = h("span", { class: "flex" }, h("span", { class: "spinner" }), " ", t("bench.running"));
|
|
else if (r?.state === "done") result = h("b", { class: "mono" }, (r.tok_s != null ? r.tok_s.toFixed(1) : "?") + " tok/s");
|
|
else if (r?.state === "error") result = h("span", { class: "err", title: (r.log || []).slice(-2).join(" · ") }, t("bench.failed"));
|
|
return h("div", { class: "flex bench-row" },
|
|
h("span", { class: "mono grow" }, m.name),
|
|
h("span", { class: "muted small" }, (m.size_bytes / 1e9).toFixed(2) + " GB"),
|
|
result,
|
|
go);
|
|
});
|
|
mount(host,
|
|
h("div", { class: "card-title" }, "⏱ " + t("bench.title")),
|
|
h("div", { class: "muted small", style: "margin-bottom:8px" }, t("bench.hint")),
|
|
...(rows.length ? rows : [h("div", { class: "empty" }, t("bench.none"))]));
|
|
if (!anyRunning && timer) { clearInterval(timer); timer = null; }
|
|
}
|
|
paint();
|
|
return host;
|
|
}
|
|
|
|
await reload();
|
|
return root;
|
|
}
|
|
|
|
function row(m, meta, reload) {
|
|
const probeBtn = h("button", { class: "btn sm ghost" }, t("models.probe"));
|
|
probeBtn.addEventListener("click", async () => {
|
|
probeBtn.disabled = true;
|
|
probeBtn.textContent = "…";
|
|
try {
|
|
const r = await api.probeModel(m.id);
|
|
if (r.ok) toast(t("models.probe.ok", { ms: r.latency_ms }), "good");
|
|
else toast(t("models.probe.fail", { err: r.error || "" }), "bad");
|
|
} catch (e) {
|
|
toast(String(e.message || e), "bad");
|
|
}
|
|
probeBtn.disabled = false;
|
|
probeBtn.textContent = t("models.probe");
|
|
});
|
|
return h(
|
|
"tr",
|
|
{},
|
|
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 || "—")),
|
|
h("td", {}, keyBadge(m.key_source || (m.api_key_env ? "missing" : "none"))),
|
|
h("td", {}, (m.caps || []).length ? h("div", { class: "flex wrap" }, ...m.caps.map((c) => h("span", { class: "badge" }, c))) : "—"),
|
|
h("td", { class: "mono" }, `${m.price_in || 0}/${m.price_out || 0}`),
|
|
h(
|
|
"td",
|
|
{ class: "right-align" },
|
|
h(
|
|
"div",
|
|
{ class: "flex", style: "justify-content:flex-end" },
|
|
probeBtn,
|
|
m.managed ? null : h("button", { class: "btn sm ghost", onClick: () => openModal(meta, m, reload) }, t("common.edit")),
|
|
h(
|
|
"button",
|
|
{
|
|
class: "btn sm danger",
|
|
onClick: async () => {
|
|
if (!(await confirmDialog(t("models.deleteConfirm", { id: m.id })))) return;
|
|
try {
|
|
await api.deleteModel(m.id);
|
|
toast(t("toast.deleted"), "good");
|
|
await reload();
|
|
} catch (e) {
|
|
toast(String(e.message || e), "bad");
|
|
}
|
|
},
|
|
},
|
|
t("common.delete")
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|