fix: honest costs in streams, oracle-sized timeouts, history & log cleanup
Cost showed $0 for every streamed request: the X-Cortiq-Cost-USD header is written before generation, when the cost cannot be known. The gateway now ships the final number in a closing SSE event (`cortiq_cost_usd`, alongside the token estimate when the provider sent no usage), and the console prefers it over the header. Money renders with 4 decimals, the last two in a smaller font — $0.0006 stays readable instead of collapsing to $0. Timeouts, both ends: the router default was 800 ms, silently degrading every prompt the hosted router escalates to its oracle (~10 s) — now 15 s. Provider clients had NO timeout at all (a dead OpenRouter host hung requests forever) — now a 10 s connect timeout, with no overall cap so long generations and streams are never cut. Benchmark history: per-run × and a Clear-all button. Logs view: a clear button backed by DELETE /admin/api/logs.
This commit is contained in:
+6
-1
@@ -185,7 +185,7 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
|
|||||||
.route("/admin/api/requests", get(get_requests))
|
.route("/admin/api/requests", get(get_requests))
|
||||||
.route("/admin/api/test", post(run_test))
|
.route("/admin/api/test", post(run_test))
|
||||||
.route("/admin/api/test/stream", post(run_test_stream))
|
.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/system", get(get_system))
|
||||||
.route("/admin/api/restart", post(restart_gateway))
|
.route("/admin/api/restart", post(restart_gateway))
|
||||||
.route("/admin/api/bench", get(bench_list).post(bench_start))
|
.route("/admin/api/bench", get(bench_list).post(bench_start))
|
||||||
@@ -2113,6 +2113,11 @@ async fn get_logs(Query(q): Query<RecentQuery>) -> ApiResult {
|
|||||||
ok(json!({ "lines": crate::logbuf::snapshot(limit) }))
|
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.
|
/// Host metrics snapshot: CPU, RAM, free disk at the models dir, gateway RSS.
|
||||||
async fn get_system(State(state): State<SharedState>) -> ApiResult {
|
async fn get_system(State(state): State<SharedState>) -> ApiResult {
|
||||||
let snap = state.sysmon.snapshot();
|
let snap = state.sysmon.snapshot();
|
||||||
|
|||||||
+4
-2
@@ -305,7 +305,9 @@ fn default_router_enabled() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
fn default_router_timeout() -> u64 {
|
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 {
|
fn default_verify_tls() -> bool {
|
||||||
true
|
true
|
||||||
@@ -736,7 +738,7 @@ impl Default for Config {
|
|||||||
router: RouterCfg {
|
router: RouterCfg {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url: "https://138.226.222.209".to_string(),
|
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
|
verify_tls: false, // the hosted endpoint is IP-bound
|
||||||
api_key_env: Some("CORTIQ_ROUTER_KEY".to_string()),
|
api_key_env: Some("CORTIQ_ROUTER_KEY".to_string()),
|
||||||
taxonomy_id: Some("data-assistant".to_string()),
|
taxonomy_id: Some("data-assistant".to_string()),
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ pub fn snapshot(limit: usize) -> Vec<String> {
|
|||||||
.collect()
|
.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) {
|
pub fn push_line(line: &str) {
|
||||||
let line = line.trim_end();
|
let line = line.trim_end();
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
|
|||||||
+24
-17
@@ -798,28 +798,35 @@ fn tap_stream(
|
|||||||
// inject one final OpenAI-shaped usage event with the gateway's
|
// inject one final OpenAI-shaped usage event with the gateway's
|
||||||
// estimate (~4 chars/token), flagged `cortiq_estimated` so nobody
|
// estimate (~4 chars/token), flagged `cortiq_estimated` so nobody
|
||||||
// mistakes it for the tokenizer's truth.
|
// mistakes it for the tokenizer's truth.
|
||||||
if done_marker && !got_usage {
|
if done_marker {
|
||||||
let est_completion = if delta_chunks > 0 {
|
// The initial X-Cortiq-Cost-USD header is always 0 — cost is
|
||||||
delta_chunks
|
// only known after generation. Ship the final number (and the
|
||||||
} else {
|
// token estimate when the provider sent none) ahead of [DONE].
|
||||||
(delta_chars / 4) as u32
|
if !got_usage {
|
||||||
};
|
prompt_tokens = (prompt_chars / 4) as u32;
|
||||||
let est_prompt = (prompt_chars / 4) as u32;
|
completion_tokens = if delta_chunks > 0 {
|
||||||
let evt = serde_json::json!({
|
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",
|
"object": "chat.completion.chunk",
|
||||||
"model": info.selected_model,
|
"model": info.selected_model,
|
||||||
"choices": [],
|
"choices": [],
|
||||||
"usage": {
|
"cortiq_cost_usd": cost,
|
||||||
"prompt_tokens": est_prompt,
|
|
||||||
"completion_tokens": est_completion,
|
|
||||||
"total_tokens": est_prompt + est_completion,
|
|
||||||
},
|
|
||||||
"cortiq_estimated": true,
|
|
||||||
});
|
});
|
||||||
|
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")));
|
yield Ok(bytes::Bytes::from(format!("data: {evt}\n\n")));
|
||||||
prompt_tokens = est_prompt;
|
|
||||||
completion_tokens = est_completion;
|
|
||||||
got_usage = true;
|
|
||||||
}
|
}
|
||||||
yield item;
|
yield item;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ impl AnthropicProvider {
|
|||||||
caps: Caps::from_list(&cfg.caps),
|
caps: Caps::from_list(&cfg.caps),
|
||||||
price_in: cfg.price_in,
|
price_in: cfg.price_in,
|
||||||
price_out: cfg.price_out,
|
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()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ impl OpenAiProvider {
|
|||||||
caps: Caps::from_list(&cfg.caps),
|
caps: Caps::from_list(&cfg.caps),
|
||||||
price_in: cfg.price_in,
|
price_in: cfg.price_in,
|
||||||
price_out: cfg.price_out,
|
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()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
@@ -585,6 +585,9 @@ body.modal-open { overflow: hidden; }
|
|||||||
/* Featured gallery: the list grows as new models are published, so give it more
|
/* Featured gallery: the list grows as new models are published, so give it more
|
||||||
room than the search grid before it starts scrolling. */
|
room than the search grid before it starts scrolling. */
|
||||||
.featured-section .hf-grid { max-height: min(62vh, 760px); }
|
.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 */
|
/* Benchmark view */
|
||||||
@keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
@keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
||||||
tbody tr:last-child { animation: bm-in .25s ease; }
|
tbody tr:last-child { animation: bm-in .25s ease; }
|
||||||
|
|||||||
Vendored
+4
-1
@@ -130,6 +130,7 @@ export const api = {
|
|||||||
// system / logs / lifecycle
|
// system / logs / lifecycle
|
||||||
system: () => j("GET", "/system"),
|
system: () => j("GET", "/system"),
|
||||||
logs: (limit = 300) => j("GET", "/logs?limit=" + limit),
|
logs: (limit = 300) => j("GET", "/logs?limit=" + limit),
|
||||||
|
clearLogs: () => j("DELETE", "/logs"),
|
||||||
restart: () => j("POST", "/restart"),
|
restart: () => j("POST", "/restart"),
|
||||||
benchStart: (model) => j("POST", "/bench", { model }),
|
benchStart: (model) => j("POST", "/bench", { model }),
|
||||||
benchList: () => j("GET", "/bench"),
|
benchList: () => j("GET", "/bench"),
|
||||||
@@ -190,6 +191,7 @@ export async function testStream(body, onDelta) {
|
|||||||
let ttftMs = null; // time to first content token
|
let ttftMs = null; // time to first content token
|
||||||
let usage = null; // OpenAI-style final-chunk usage, when the provider sends it
|
let usage = null; // OpenAI-style final-chunk usage, when the provider sends it
|
||||||
let usageEstimated = false; // gateway-injected estimate rather than tokenizer truth
|
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 (;;) {
|
for (;;) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
@@ -207,6 +209,7 @@ export async function testStream(body, onDelta) {
|
|||||||
usage = chunk.usage;
|
usage = chunk.usage;
|
||||||
usageEstimated = !!chunk.cortiq_estimated;
|
usageEstimated = !!chunk.cortiq_estimated;
|
||||||
}
|
}
|
||||||
|
if (chunk.cortiq_cost_usd != null) costUsd = chunk.cortiq_cost_usd;
|
||||||
const d = chunk.choices?.[0]?.delta?.content;
|
const d = chunk.choices?.[0]?.delta?.content;
|
||||||
if (d) {
|
if (d) {
|
||||||
if (ttftMs == null) ttftMs = Math.round(performance.now() - t0);
|
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"),
|
score: parseFloat(h.get("x-cortiq-complexity-score") || "0"),
|
||||||
selected_model: h.get("x-cortiq-selected-model") || "",
|
selected_model: h.get("x-cortiq-selected-model") || "",
|
||||||
route_source: h.get("x-cortiq-route-source") || "",
|
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,
|
||||||
usage_estimated: usageEstimated,
|
usage_estimated: usageEstimated,
|
||||||
ttft_ms: ttftMs,
|
ttft_ms: ttftMs,
|
||||||
|
|||||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
|||||||
"bm.liveRunning": "läuft…",
|
"bm.liveRunning": "läuft…",
|
||||||
"bm.spent": "ausgegeben",
|
"bm.spent": "ausgegeben",
|
||||||
"bm.verdict": "🏁 Fertig: Antworten {p}, ausgegeben {s}",
|
"bm.verdict": "🏁 Fertig: Antworten {p}, ausgegeben {s}",
|
||||||
|
"bm.clearHistory": "Alle löschen",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
@@ -594,4 +594,5 @@ export default {
|
|||||||
"bm.liveRunning": "running…",
|
"bm.liveRunning": "running…",
|
||||||
"bm.spent": "spent",
|
"bm.spent": "spent",
|
||||||
"bm.verdict": "🏁 Done: answers {p}, spent {s}",
|
"bm.verdict": "🏁 Done: answers {p}, spent {s}",
|
||||||
|
"bm.clearHistory": "Clear all",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
|||||||
"bm.liveRunning": "en curso…",
|
"bm.liveRunning": "en curso…",
|
||||||
"bm.spent": "gastado",
|
"bm.spent": "gastado",
|
||||||
"bm.verdict": "🏁 Listo: respuestas {p}, gastado {s}",
|
"bm.verdict": "🏁 Listo: respuestas {p}, gastado {s}",
|
||||||
|
"bm.clearHistory": "Borrar todo",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
|||||||
"bm.liveRunning": "en cours…",
|
"bm.liveRunning": "en cours…",
|
||||||
"bm.spent": "dépensé",
|
"bm.spent": "dépensé",
|
||||||
"bm.verdict": "🏁 Terminé : réponses {p}, dépensé {s}",
|
"bm.verdict": "🏁 Terminé : réponses {p}, dépensé {s}",
|
||||||
|
"bm.clearHistory": "Tout effacer",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
@@ -593,4 +593,5 @@ export default {
|
|||||||
"bm.liveRunning": "идёт…",
|
"bm.liveRunning": "идёт…",
|
||||||
"bm.spent": "потрачено",
|
"bm.spent": "потрачено",
|
||||||
"bm.verdict": "🏁 Готово: ответы {p}, потрачено {s}",
|
"bm.verdict": "🏁 Готово: ответы {p}, потрачено {s}",
|
||||||
|
"bm.clearHistory": "Очистить всё",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
|||||||
"bm.liveRunning": "sürüyor…",
|
"bm.liveRunning": "sürüyor…",
|
||||||
"bm.spent": "harcanan",
|
"bm.spent": "harcanan",
|
||||||
"bm.verdict": "🏁 Bitti: yanıtlar {p}, harcanan {s}",
|
"bm.verdict": "🏁 Bitti: yanıtlar {p}, harcanan {s}",
|
||||||
|
"bm.clearHistory": "Tümünü temizle",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
|||||||
"bm.liveRunning": "进行中……",
|
"bm.liveRunning": "进行中……",
|
||||||
"bm.spent": "花费",
|
"bm.spent": "花费",
|
||||||
"bm.verdict": "🏁 完成:回答 {p},花费 {s}",
|
"bm.verdict": "🏁 完成:回答 {p},花费 {s}",
|
||||||
|
"bm.clearHistory": "全部清除",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+13
-2
@@ -95,11 +95,22 @@ export function confirmDialog(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// number formatting
|
// 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) {
|
export function money(n) {
|
||||||
if (n == null) return "—";
|
if (n == null) return "—";
|
||||||
if (n === 0) return "$0";
|
if (n === 0) return "$0";
|
||||||
if (Math.abs(n) < 0.01) return "$" + n.toFixed(6);
|
// up to 4 decimals, trailing zeros trimmed (but keep money-like 2 minimum)
|
||||||
return "$" + n.toFixed(4);
|
let s = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
|
||||||
|
if (/\.\d$/.test(s)) s += "0";
|
||||||
|
return "$" + s;
|
||||||
}
|
}
|
||||||
export function ms(n) {
|
export function ms(n) {
|
||||||
return n == null ? "—" : `${Math.round(Number(n))} ms`;
|
return n == null ? "—" : `${Math.round(Number(n))} ms`;
|
||||||
|
|||||||
Vendored
+24
-8
@@ -7,7 +7,7 @@
|
|||||||
// A redesign of the old standalone benchmark.html: same scoring ideas
|
// A redesign of the old standalone benchmark.html: same scoring ideas
|
||||||
// (keyword checks with | synonyms, think-tag stripping, anti-loop detection,
|
// (keyword checks with | synonyms, think-tag stripping, anti-loop detection,
|
||||||
// pass/partial/fail), now embedded, localized and CDN-free.
|
// 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 { t } from "../i18n.js";
|
||||||
import { api, testStream } from "../api.js";
|
import { api, testStream } from "../api.js";
|
||||||
|
|
||||||
@@ -60,6 +60,9 @@ function pushHistory(run) {
|
|||||||
l.unshift(run);
|
l.unshift(run);
|
||||||
localStorage.setItem(LS_HISTORY, JSON.stringify(l.slice(0, 12)));
|
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) ─────────────────────
|
// ── evaluation (ported from the old benchmark, trimmed) ─────────────────────
|
||||||
function stripThink(text) {
|
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" }, 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.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" }, 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("div", { class: "flex wrap", style: "gap:6px; margin-top:4px" },
|
||||||
h("span", { class: "muted small" }, t("bm.modelDist") + ":"),
|
h("span", { class: "muted small" }, t("bm.modelDist") + ":"),
|
||||||
...Object.entries(dist).map(([m, d]) =>
|
...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 {
|
} else {
|
||||||
const sorted = models.slice().sort((a, b) => stats(run.models[b]).score - stats(run.models[a]).score);
|
const sorted = models.slice().sort((a, b) => stats(run.models[b]).score - stats(run.models[a]).score);
|
||||||
summary = h("div", {},
|
summary = h("div", {},
|
||||||
@@ -278,7 +281,7 @@ export async function renderBenchmark() {
|
|||||||
h("span", { class: "badge " + (pct >= 80 ? "ok" : pct >= 50 ? "" : "bad") }, pct + "%"),
|
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.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.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}%` })));
|
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", {}, r.tier ? h("span", { class: "badge tier-" + r.tier }, r.tier) : "—"),
|
||||||
h("td", {},
|
h("td", {},
|
||||||
h("span", { class: "mono small" }, r.chosen || "—"),
|
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),
|
r.source && r.source.includes("task-rule") ? h("div", { class: "badge", style: "margin-top:2px" }, "task-rule") : null),
|
||||||
answerCell(r, r.chosen || "cortiq-auto")));
|
answerCell(r, r.chosen || "cortiq-auto")));
|
||||||
} else {
|
} else {
|
||||||
@@ -350,9 +353,16 @@ export async function renderBenchmark() {
|
|||||||
mount(historyHost);
|
mount(historyHost);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const clearAll = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("bm.clearHistory"));
|
||||||
|
clearAll.addEventListener("click", () => {
|
||||||
|
saveHistory([]);
|
||||||
|
renderHistory();
|
||||||
|
});
|
||||||
mount(historyHost, h("div", { class: "card" },
|
mount(historyHost, h("div", { class: "card" },
|
||||||
h("div", { class: "card-title" }, t("bm.history")),
|
h("div", { class: "card-head" },
|
||||||
...l.map((run) => {
|
h("h3", {}, t("bm.history")),
|
||||||
|
h("div", { class: "right" }, clearAll)),
|
||||||
|
...l.map((run, idx) => {
|
||||||
const models = Object.keys(run.models);
|
const models = Object.keys(run.models);
|
||||||
const scoreOf = (m) => {
|
const scoreOf = (m) => {
|
||||||
const rs = run.models[m].results || [];
|
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: "muted small" }, new Date(run.ts).toLocaleString()),
|
||||||
h("span", { class: "grow mono small" }, run.mode === "routing" ? t("bm.mode.routing") : models.join(", ")),
|
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,
|
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));
|
row.addEventListener("click", () => renderResults(run));
|
||||||
return row;
|
return row;
|
||||||
})));
|
})));
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// Dashboard — KPIs, time-series charts, breakdown, recent requests, health.
|
// 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 { t } from "../i18n.js";
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { lineChart, barList } from "../charts.js";
|
import { lineChart, barList } from "../charts.js";
|
||||||
@@ -161,7 +161,7 @@ export async function renderDashboard() {
|
|||||||
{ class: "kpis" },
|
{ class: "kpis" },
|
||||||
kpi(num(tot.requests), t("dash.kpi.requests")),
|
kpi(num(tot.requests), t("dash.kpi.requests")),
|
||||||
kpi(num(tot.total_tokens), t("dash.kpi.tokens")),
|
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(ms(tot.avg_latency_ms), t("dash.kpi.latency")),
|
||||||
kpi(pct(tot.success_rate), t("dash.kpi.success")),
|
kpi(pct(tot.success_rate), t("dash.kpi.success")),
|
||||||
kpi(num(tot.failovers), t("dash.kpi.failovers"))
|
kpi(num(tot.failovers), t("dash.kpi.failovers"))
|
||||||
|
|||||||
Vendored
+12
-1
@@ -21,6 +21,17 @@ export function renderLogs() {
|
|||||||
);
|
);
|
||||||
const pauseBtn = h("button", { class: "btn ghost sm" }, "⏸ " + t("logs.pause"));
|
const pauseBtn = h("button", { class: "btn ghost sm" }, "⏸ " + t("logs.pause"));
|
||||||
const copyBtn = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy"));
|
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 = [];
|
let lines = [];
|
||||||
function paint() {
|
function paint() {
|
||||||
@@ -76,7 +87,7 @@ export function renderLogs() {
|
|||||||
h(
|
h(
|
||||||
"div",
|
"div",
|
||||||
{ class: "card" },
|
{ 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
|
pre
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
// video → `cortiq animate` over a local text-to-video .cmf (MiniMax-H3)
|
// video → `cortiq animate` over a local text-to-video .cmf (MiniMax-H3)
|
||||||
// Media results are decoded in the browser (PPM → canvas, MJPEG AVI → canvas
|
// 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.
|
// 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 { t } from "../i18n.js";
|
||||||
import { api, testStream, mediaFileUrl, mediaFileBytes } from "../api.js";
|
import { api, testStream, mediaFileUrl, mediaFileBytes } from "../api.js";
|
||||||
import { appState } from "../app.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.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.tokSpeed"), tokS != null ? tokS + " tok/s" : "—"),
|
||||||
kv(t("pg.ttft"), r.ttft_ms != null ? ms(r.ttft_ms) : "—"),
|
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.latency"), ms(r.latency_ms)),
|
||||||
kv(t("pg.failover"), c.failover ? "yes" : "no")
|
kv(t("pg.failover"), c.failover ? "yes" : "no")
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user