diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 3fd6a00..bf3ac6f 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -185,7 +185,7 @@ pub fn api_routes(admin_token: String) -> Router { .route("/admin/api/requests", get(get_requests)) .route("/admin/api/test", post(run_test)) .route("/admin/api/test/stream", post(run_test_stream)) - .route("/admin/api/logs", get(get_logs)) + .route("/admin/api/logs", get(get_logs).delete(clear_logs)) .route("/admin/api/system", get(get_system)) .route("/admin/api/restart", post(restart_gateway)) .route("/admin/api/bench", get(bench_list).post(bench_start)) @@ -2113,6 +2113,11 @@ async fn get_logs(Query(q): Query) -> ApiResult { ok(json!({ "lines": crate::logbuf::snapshot(limit) })) } +async fn clear_logs() -> ApiResult { + crate::logbuf::clear(); + ok(json!({ "ok": true })) +} + /// Host metrics snapshot: CPU, RAM, free disk at the models dir, gateway RSS. async fn get_system(State(state): State) -> ApiResult { let snap = state.sysmon.snapshot(); diff --git a/src/config.rs b/src/config.rs index dfa871c..87a9b6f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -305,7 +305,9 @@ fn default_router_enabled() -> bool { true } fn default_router_timeout() -> u64 { - 800 + // the hosted router escalates hard prompts to an oracle (~10 s); + // an 800 ms budget silently degraded every one of those to the fallback + 15_000 } fn default_verify_tls() -> bool { true @@ -736,7 +738,7 @@ impl Default for Config { router: RouterCfg { enabled: true, url: "https://138.226.222.209".to_string(), - timeout_ms: 12_000, // hard prompts escalate to an oracle (~10 s) + timeout_ms: 15_000, // hard prompts escalate to an oracle (~10 s) verify_tls: false, // the hosted endpoint is IP-bound api_key_env: Some("CORTIQ_ROUTER_KEY".to_string()), taxonomy_id: Some("data-assistant".to_string()), diff --git a/src/logbuf.rs b/src/logbuf.rs index 3d8e25b..37a7463 100644 --- a/src/logbuf.rs +++ b/src/logbuf.rs @@ -23,6 +23,11 @@ pub fn snapshot(limit: usize) -> Vec { .collect() } +/// Drop everything in the ring (the admin console's "clear" button). +pub fn clear() { + ring().lock().unwrap().clear(); +} + pub fn push_line(line: &str) { let line = line.trim_end(); if line.is_empty() { diff --git a/src/pipeline.rs b/src/pipeline.rs index 387ac6c..4819c5f 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -798,28 +798,35 @@ fn tap_stream( // inject one final OpenAI-shaped usage event with the gateway's // estimate (~4 chars/token), flagged `cortiq_estimated` so nobody // mistakes it for the tokenizer's truth. - if done_marker && !got_usage { - let est_completion = if delta_chunks > 0 { - delta_chunks - } else { - (delta_chars / 4) as u32 - }; - let est_prompt = (prompt_chars / 4) as u32; - let evt = serde_json::json!({ + if done_marker { + // The initial X-Cortiq-Cost-USD header is always 0 — cost is + // only known after generation. Ship the final number (and the + // token estimate when the provider sent none) ahead of [DONE]. + if !got_usage { + prompt_tokens = (prompt_chars / 4) as u32; + completion_tokens = if delta_chunks > 0 { + delta_chunks + } else { + (delta_chars / 4) as u32 + }; + } + let cost = provider.price(prompt_tokens, completion_tokens); + let mut evt = serde_json::json!({ "object": "chat.completion.chunk", "model": info.selected_model, "choices": [], - "usage": { - "prompt_tokens": est_prompt, - "completion_tokens": est_completion, - "total_tokens": est_prompt + est_completion, - }, - "cortiq_estimated": true, + "cortiq_cost_usd": cost, }); + if !got_usage { + evt["usage"] = serde_json::json!({ + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }); + evt["cortiq_estimated"] = serde_json::json!(true); + got_usage = true; + } yield Ok(bytes::Bytes::from(format!("data: {evt}\n\n"))); - prompt_tokens = est_prompt; - completion_tokens = est_completion; - got_usage = true; } yield item; } diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index f5934df..fcab748 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -32,7 +32,13 @@ impl AnthropicProvider { caps: Caps::from_list(&cfg.caps), price_in: cfg.price_in, price_out: cfg.price_out, - http: reqwest::Client::new(), + // connect timeout: a dead host fails over in seconds instead of + // hanging the request; NO overall timeout — that would cut long + // generations and streams (OpenRouter can be slow legitimately) + http: reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), }) } diff --git a/src/providers/openai.rs b/src/providers/openai.rs index e081101..2c3ade6 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -28,7 +28,13 @@ impl OpenAiProvider { caps: Caps::from_list(&cfg.caps), price_in: cfg.price_in, price_out: cfg.price_out, - http: reqwest::Client::new(), + // connect timeout: a dead host fails over in seconds instead of + // hanging the request; NO overall timeout — that would cut long + // generations and streams (OpenRouter can be slow legitimately) + http: reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), }) } diff --git a/web/css/styles.css b/web/css/styles.css index c452001..6bcacfe 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -585,6 +585,9 @@ body.modal-open { overflow: hidden; } /* Featured gallery: the list grows as new models are published, so give it more room than the search grid before it starts scrolling. */ .featured-section .hf-grid { max-height: min(62vh, 760px); } +/* Money: 4 decimals, last two de-emphasized */ +.money-tail { font-size: .72em; opacity: .75; margin-left: 1px; } + /* Benchmark view */ @keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } } tbody tr:last-child { animation: bm-in .25s ease; } diff --git a/web/js/api.js b/web/js/api.js index 3214772..ed46b36 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -130,6 +130,7 @@ export const api = { // system / logs / lifecycle system: () => j("GET", "/system"), logs: (limit = 300) => j("GET", "/logs?limit=" + limit), + clearLogs: () => j("DELETE", "/logs"), restart: () => j("POST", "/restart"), benchStart: (model) => j("POST", "/bench", { model }), benchList: () => j("GET", "/bench"), @@ -190,6 +191,7 @@ export async function testStream(body, onDelta) { let ttftMs = null; // time to first content token let usage = null; // OpenAI-style final-chunk usage, when the provider sends it let usageEstimated = false; // gateway-injected estimate rather than tokenizer truth + let costUsd = null; // final cost from the gateway's closing event (headers carry 0) for (;;) { const { done, value } = await reader.read(); if (done) break; @@ -207,6 +209,7 @@ export async function testStream(body, onDelta) { usage = chunk.usage; usageEstimated = !!chunk.cortiq_estimated; } + if (chunk.cortiq_cost_usd != null) costUsd = chunk.cortiq_cost_usd; const d = chunk.choices?.[0]?.delta?.content; if (d) { if (ttftMs == null) ttftMs = Math.round(performance.now() - t0); @@ -224,7 +227,7 @@ export async function testStream(body, onDelta) { score: parseFloat(h.get("x-cortiq-complexity-score") || "0"), selected_model: h.get("x-cortiq-selected-model") || "", route_source: h.get("x-cortiq-route-source") || "", - cost_usd: parseFloat(h.get("x-cortiq-cost-usd") || "0"), + cost_usd: costUsd != null ? costUsd : parseFloat(h.get("x-cortiq-cost-usd") || "0"), usage, usage_estimated: usageEstimated, ttft_ms: ttftMs, diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index 5d7ba4b..cf0b27f 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -601,4 +601,5 @@ export default { "bm.liveRunning": "läuft…", "bm.spent": "ausgegeben", "bm.verdict": "🏁 Fertig: Antworten {p}, ausgegeben {s}", + "bm.clearHistory": "Alle löschen", }; diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index a306bee..992ceb1 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -594,4 +594,5 @@ export default { "bm.liveRunning": "running…", "bm.spent": "spent", "bm.verdict": "🏁 Done: answers {p}, spent {s}", + "bm.clearHistory": "Clear all", }; diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index a97db37..a5ba6a6 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -601,4 +601,5 @@ export default { "bm.liveRunning": "en curso…", "bm.spent": "gastado", "bm.verdict": "🏁 Listo: respuestas {p}, gastado {s}", + "bm.clearHistory": "Borrar todo", }; diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index b3fa03e..6a32c08 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -601,4 +601,5 @@ export default { "bm.liveRunning": "en cours…", "bm.spent": "dépensé", "bm.verdict": "🏁 Terminé : réponses {p}, dépensé {s}", + "bm.clearHistory": "Tout effacer", }; diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 867be0e..cf41947 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -593,4 +593,5 @@ export default { "bm.liveRunning": "идёт…", "bm.spent": "потрачено", "bm.verdict": "🏁 Готово: ответы {p}, потрачено {s}", + "bm.clearHistory": "Очистить всё", }; diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index 91061e6..1e35cdd 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -601,4 +601,5 @@ export default { "bm.liveRunning": "sürüyor…", "bm.spent": "harcanan", "bm.verdict": "🏁 Bitti: yanıtlar {p}, harcanan {s}", + "bm.clearHistory": "Tümünü temizle", }; diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index 2b266a0..abe55e0 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -601,4 +601,5 @@ export default { "bm.liveRunning": "进行中……", "bm.spent": "花费", "bm.verdict": "🏁 完成:回答 {p},花费 {s}", + "bm.clearHistory": "全部清除", }; diff --git a/web/js/ui.js b/web/js/ui.js index 38f3402..7113d4c 100644 --- a/web/js/ui.js +++ b/web/js/ui.js @@ -95,11 +95,22 @@ export function confirmDialog(message) { } // number formatting +// Money as a DOM node: 4 decimals, the last two rendered smaller — tiny +// per-request prices stay readable without shouting six zeros. +export function moneyEl(n) { + if (n == null) return document.createTextNode("—"); + if (n === 0) return document.createTextNode("$0"); + const s = n.toFixed(4); + return h("span", { class: "money" }, "$" + s.slice(0, -2), h("small", { class: "money-tail" }, s.slice(-2))); +} + export function money(n) { if (n == null) return "—"; if (n === 0) return "$0"; - if (Math.abs(n) < 0.01) return "$" + n.toFixed(6); - return "$" + n.toFixed(4); + // up to 4 decimals, trailing zeros trimmed (but keep money-like 2 minimum) + let s = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, ""); + if (/\.\d$/.test(s)) s += "0"; + return "$" + s; } export function ms(n) { return n == null ? "—" : `${Math.round(Number(n))} ms`; diff --git a/web/js/views/benchmark.js b/web/js/views/benchmark.js index 01bada9..aa53427 100644 --- a/web/js/views/benchmark.js +++ b/web/js/views/benchmark.js @@ -7,7 +7,7 @@ // A redesign of the old standalone benchmark.html: same scoring ideas // (keyword checks with | synonyms, think-tag stripping, anti-loop detection, // pass/partial/fail), now embedded, localized and CDN-free. -import { h, mount, toast, ms, money } from "../ui.js"; +import { h, mount, toast, ms, money, moneyEl } from "../ui.js"; import { t } from "../i18n.js"; import { api, testStream } from "../api.js"; @@ -60,6 +60,9 @@ function pushHistory(run) { l.unshift(run); localStorage.setItem(LS_HISTORY, JSON.stringify(l.slice(0, 12))); } +function saveHistory(l) { + localStorage.setItem(LS_HISTORY, JSON.stringify(l)); +} // ── evaluation (ported from the old benchmark, trimmed) ───────────────────── function stripThink(text) { @@ -261,11 +264,11 @@ export async function renderBenchmark() { h("div", { class: "kpi" }, h("div", { class: "big" }, judged.length ? `${okCount}/${judged.length}` : "—"), h("div", { class: "lbl" }, t("bm.classAcc"))), h("div", { class: "kpi" }, h("div", { class: "big" }, st.ttft != null ? ms(Math.round(st.ttft)) : "—"), h("div", { class: "lbl" }, t("pg.ttft"))), h("div", { class: "kpi" }, h("div", { class: "big" }, st.tokS != null ? st.tokS.toFixed(1) : "—"), h("div", { class: "lbl" }, "tok/s")), - h("div", { class: "kpi" }, h("div", { class: "big" }, money(st.cost)), h("div", { class: "lbl" }, t("bm.spent")))), + h("div", { class: "kpi" }, h("div", { class: "big" }, moneyEl(st.cost)), h("div", { class: "lbl" }, t("bm.spent")))), h("div", { class: "flex wrap", style: "gap:6px; margin-top:4px" }, h("span", { class: "muted small" }, t("bm.modelDist") + ":"), ...Object.entries(dist).map(([m, d]) => - h("span", { class: "badge" }, `${m} × ${d.n}` + (d.cost > 0 ? ` · ${money(d.cost)}` : ""))))); + h("span", { class: "badge" }, `${m} × ${d.n}`, d.cost > 0 ? " · " : "", d.cost > 0 ? moneyEl(d.cost) : "")))); } else { const sorted = models.slice().sort((a, b) => stats(run.models[b]).score - stats(run.models[a]).score); summary = h("div", {}, @@ -278,7 +281,7 @@ export async function renderBenchmark() { h("span", { class: "badge " + (pct >= 80 ? "ok" : pct >= 50 ? "" : "bad") }, pct + "%"), st.tokS != null ? h("span", { class: "muted small", style: "margin-left:8px" }, st.tokS.toFixed(1) + " tok/s") : null, st.ttft != null ? h("span", { class: "muted small", style: "margin-left:8px" }, "TTFT " + ms(Math.round(st.ttft))) : null, - st.cost > 0 ? h("span", { class: "muted small", style: "margin-left:8px" }, t("bm.spent") + " " + money(st.cost)) : null), + st.cost > 0 ? h("span", { class: "muted small", style: "margin-left:8px" }, t("bm.spent") + " ", moneyEl(st.cost)) : null), h("div", { class: "sysbar" }, h("i", { class: pct < 50 ? "bad" : "", style: `width:${pct}%` }))); })); } @@ -316,7 +319,7 @@ export async function renderBenchmark() { h("td", {}, r.tier ? h("span", { class: "badge tier-" + r.tier }, r.tier) : "—"), h("td", {}, h("span", { class: "mono small" }, r.chosen || "—"), - r.cost > 0 ? h("div", { class: "muted small" }, money(r.cost)) : null, + r.cost > 0 ? h("div", { class: "muted small" }, moneyEl(r.cost)) : null, r.source && r.source.includes("task-rule") ? h("div", { class: "badge", style: "margin-top:2px" }, "task-rule") : null), answerCell(r, r.chosen || "cortiq-auto"))); } else { @@ -350,9 +353,16 @@ export async function renderBenchmark() { mount(historyHost); return; } + const clearAll = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("bm.clearHistory")); + clearAll.addEventListener("click", () => { + saveHistory([]); + renderHistory(); + }); mount(historyHost, h("div", { class: "card" }, - h("div", { class: "card-title" }, t("bm.history")), - ...l.map((run) => { + h("div", { class: "card-head" }, + h("h3", {}, t("bm.history")), + h("div", { class: "right" }, clearAll)), + ...l.map((run, idx) => { const models = Object.keys(run.models); const scoreOf = (m) => { const rs = run.models[m].results || []; @@ -364,7 +374,13 @@ export async function renderBenchmark() { h("span", { class: "muted small" }, new Date(run.ts).toLocaleString()), h("span", { class: "grow mono small" }, run.mode === "routing" ? t("bm.mode.routing") : models.join(", ")), run.quick ? h("span", { class: "badge" }, t("bm.quick")) : null, - best ? h("b", {}, Math.round(scoreOf(best) * 100) + "%") : null); + best ? h("b", {}, Math.round(scoreOf(best) * 100) + "%") : null, + h("button", { class: "btn sm ghost danger", title: t("common.delete"), onClick: (e) => { + e.stopPropagation(); + l.splice(idx, 1); + saveHistory(l); + renderHistory(); + } }, "×")); row.addEventListener("click", () => renderResults(run)); return row; }))); diff --git a/web/js/views/dashboard.js b/web/js/views/dashboard.js index 75f5f2d..346d70a 100644 --- a/web/js/views/dashboard.js +++ b/web/js/views/dashboard.js @@ -1,5 +1,5 @@ // Dashboard — KPIs, time-series charts, breakdown, recent requests, health. -import { h, mount, money, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js"; +import { h, mount, money, moneyEl, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js"; import { t } from "../i18n.js"; import { api } from "../api.js"; import { lineChart, barList } from "../charts.js"; @@ -161,7 +161,7 @@ export async function renderDashboard() { { class: "kpis" }, kpi(num(tot.requests), t("dash.kpi.requests")), kpi(num(tot.total_tokens), t("dash.kpi.tokens")), - kpi(money(tot.cost_usd), t("dash.kpi.cost")), + kpi(moneyEl(tot.cost_usd), t("dash.kpi.cost")), kpi(ms(tot.avg_latency_ms), t("dash.kpi.latency")), kpi(pct(tot.success_rate), t("dash.kpi.success")), kpi(num(tot.failovers), t("dash.kpi.failovers")) diff --git a/web/js/views/logs.js b/web/js/views/logs.js index 5a208a4..672cec7 100644 --- a/web/js/views/logs.js +++ b/web/js/views/logs.js @@ -21,6 +21,17 @@ export function renderLogs() { ); const pauseBtn = h("button", { class: "btn ghost sm" }, "⏸ " + t("logs.pause")); const copyBtn = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy")); + const clearBtn = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("dash.clear")); + clearBtn.addEventListener("click", async () => { + try { + await api.clearLogs(); + lines = []; + paint(); + toast(t("dash.cleared"), "ok"); + } catch (e) { + toast(String(e.message || e), "err"); + } + }); let lines = []; function paint() { @@ -76,7 +87,7 @@ export function renderLogs() { h( "div", { class: "card" }, - h("div", { class: "flex", style: "gap:8px; flex-wrap:wrap; margin-bottom:10px" }, filterIn, levelSel, pauseBtn, copyBtn), + h("div", { class: "flex", style: "gap:8px; flex-wrap:wrap; margin-bottom:10px" }, filterIn, levelSel, pauseBtn, copyBtn, clearBtn), pre ) ); diff --git a/web/js/views/playground.js b/web/js/views/playground.js index ee9cb3f..9f4fc56 100644 --- a/web/js/views/playground.js +++ b/web/js/views/playground.js @@ -4,7 +4,7 @@ // video → `cortiq animate` over a local text-to-video .cmf (MiniMax-H3) // Media results are decoded in the browser (PPM → canvas, MJPEG AVI → canvas // player synced to the wav) — the same no-ffmpeg stance as the engine itself. -import { h, mount, toast, money, ms } from "../ui.js"; +import { h, mount, toast, money, moneyEl, ms } from "../ui.js"; import { t } from "../i18n.js"; import { api, testStream, mediaFileUrl, mediaFileBytes } from "../api.js"; import { appState } from "../app.js"; @@ -218,7 +218,7 @@ export async function renderPlayground() { kv(t("pg.tokens"), haveUsage ? `${r.usage_estimated ? "≈ " : ""}${u.prompt_tokens ?? 0} / ${u.completion_tokens ?? 0}` : "—"), kv(t("pg.tokSpeed"), tokS != null ? tokS + " tok/s" : "—"), kv(t("pg.ttft"), r.ttft_ms != null ? ms(r.ttft_ms) : "—"), - kv(t("pg.cost"), money(c.cost_usd)), + kv(t("pg.cost"), moneyEl(c.cost_usd)), kv(t("pg.latency"), ms(r.latency_ms)), kv(t("pg.failover"), c.failover ? "yes" : "no") );