diff --git a/bin/cli/commands/combo.mjs b/bin/cli/commands/combo.mjs index d2a156bedc..9c786eb31e 100644 --- a/bin/cli/commands/combo.mjs +++ b/bin/cli/commands/combo.mjs @@ -2,6 +2,8 @@ import { Option } from "commander"; import { printHeading } from "../io.mjs"; import { withRuntime } from "../runtime.mjs"; import { t } from "../i18n.mjs"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; const VALID_STRATEGIES = [ "priority", @@ -20,6 +22,79 @@ const VALID_STRATEGIES = [ "reset-aware", ]; +const suggestSchema = [ + { key: "rank", header: "#" }, + { key: "name", header: "Combo", width: 24 }, + { key: "strategy", header: "Strategy", width: 16 }, + { key: "score", header: "Score", formatter: (v) => (v != null ? v.toFixed(3) : "-") }, + { key: "latencyP50Ms", header: "Latency P50", formatter: (v) => (v != null ? `${v}ms` : "-") }, + { key: "costPer1k", header: "Cost/1k", formatter: (v) => (v != null ? `$${v.toFixed(5)}` : "-") }, + { + key: "rationale", + header: "Rationale", + width: 40, + formatter: (v) => { + if (!v) return "-"; + const s = String(v); + return s.length > 40 ? s.slice(0, 39) + "…" : s; + }, + }, +]; + +export function extendComboSuggest(combo) { + combo + .command("suggest") + .description(t("combo.suggest.description")) + .requiredOption("--task ", t("combo.suggest.task")) + .option("--max-cost ", t("combo.suggest.maxCost"), parseFloat) + .option("--max-latency-ms ", t("combo.suggest.maxLatencyMs"), parseInt) + .option("--weights ", t("combo.suggest.weights")) + .option("--top ", t("combo.suggest.top"), parseInt, 5) + .option("--explain", t("combo.suggest.explain")) + .option("--switch", t("combo.suggest.switch")) + .action(async (opts, cmd) => { + const body = { + task: opts.task, + constraints: { + maxCostUsd: opts.maxCost, + maxLatencyMs: opts.maxLatencyMs, + }, + weights: opts.weights ? JSON.parse(opts.weights) : undefined, + top: opts.top, + }; + const res = await apiFetch("/api/mcp/tools/call", { + method: "POST", + body: { name: "omniroute_best_combo_for_task", arguments: body }, + }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + const candidates = data.candidates ?? data; + const rows = (Array.isArray(candidates) ? candidates : []).map((c, i) => ({ + rank: i + 1, + ...c, + })); + emit(rows, cmd.optsWithGlobals(), suggestSchema); + if (opts.explain && !cmd.optsWithGlobals().quiet) { + process.stderr.write(`\nRationale:\n${data.rationale ?? "(no rationale)"}\n`); + } + if (opts.switch && rows[0]) { + const best = rows[0].name; + const switchRes = await apiFetch("/api/combos/switch", { + method: "POST", + body: { name: best }, + }); + if (!switchRes.ok) { + process.stderr.write(`Switch failed: ${switchRes.status}\n`); + process.exit(1); + } + process.stderr.write(`\nSwitched to: ${best}\n`); + } + }); +} + export function registerCombo(program) { const combo = program.command("combo").description(t("combo.title")); @@ -68,6 +143,8 @@ export function registerCombo(program) { const exitCode = await runComboDeleteCommand(name, { ...opts, output: globalOpts.output }); if (exitCode !== 0) process.exit(exitCode); }); + + extendComboSuggest(combo); } export async function runComboListCommand(opts = {}) { diff --git a/bin/cli/commands/context-eng.mjs b/bin/cli/commands/context-eng.mjs new file mode 100644 index 0000000000..7651cf37ea --- /dev/null +++ b/bin/cli/commands/context-eng.mjs @@ -0,0 +1,182 @@ +import { readFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +async function confirm(q) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${q} [y/N] `, (a) => { + rl.close(); + resolve(a.trim().toLowerCase() === "y"); + }); + }); +} + +export function registerContextEng(program) { + const ctx = program.command("context-eng").alias("ctx").description(t("context.description")); + + ctx + .command("analytics") + .option("--period

", t("context.analytics.period"), "7d") + .action(async (opts, cmd) => { + const res = await apiFetch(`/api/context/analytics?period=${opts.period}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + const caveman = ctx.command("caveman").description(t("context.caveman.description")); + const cmCfg = caveman.command("config").description(t("context.caveman.config.description")); + + cmCfg.command("show").action(async (opts, cmd) => { + const res = await apiFetch("/api/context/caveman/config"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + cmCfg + .command("set") + .option("--aggressiveness ", t("context.caveman.config.aggressiveness"), parseFloat) + .option("--max-shrink-pct ", t("context.caveman.config.maxShrinkPct"), parseInt) + .option("--preserve-tags ", t("context.caveman.config.preserveTags"), (v) => v.split(",")) + .action(async (opts, cmd) => { + const body = {}; + if (opts.aggressiveness !== undefined) body.aggressiveness = opts.aggressiveness; + if (opts.maxShrinkPct !== undefined) body.maxShrinkPct = opts.maxShrinkPct; + if (opts.preserveTags) body.preserveTags = opts.preserveTags; + const res = await apiFetch("/api/context/caveman/config", { method: "PUT", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + const rtk = ctx.command("rtk").description(t("context.rtk.description")); + const rtkCfg = rtk.command("config").description(t("context.rtk.config.description")); + + rtkCfg.command("show").action(async (opts, cmd) => { + const res = await apiFetch("/api/context/rtk/config"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + rtkCfg + .command("set") + .option("--token-budget ", t("context.rtk.config.tokenBudget"), parseInt) + .option("--reserve-pct ", t("context.rtk.config.reservePct"), parseInt) + .action(async (opts, cmd) => { + const body = {}; + if (opts.tokenBudget) body.tokenBudget = opts.tokenBudget; + if (opts.reservePct) body.reservePct = opts.reservePct; + const res = await apiFetch("/api/context/rtk/config", { method: "PUT", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + const filters = rtk.command("filters").description(t("context.rtk.filters.description")); + + filters.command("list").action(async (opts, cmd) => { + const res = await apiFetch("/api/context/rtk/filters"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + filters + .command("add") + .requiredOption("--pattern

", t("context.rtk.filters.pattern")) + .option("--priority ", t("context.rtk.filters.priority"), parseInt, 100) + .option("--action ", t("context.rtk.filters.action"), "drop") + .action(async (opts, cmd) => { + const body = { pattern: opts.pattern, priority: opts.priority, action: opts.action }; + const res = await apiFetch("/api/context/rtk/filters", { method: "POST", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + filters + .command("remove ") + .option("--yes", t("context.rtk.filters.yes")) + .action(async (id, opts, cmd) => { + if (!opts.yes) { + const ok = await confirm(`Remove filter ${id}?`); + if (!ok) return; + } + const res = await apiFetch(`/api/context/rtk/filters/${id}`, { method: "DELETE" }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write("Removed\n"); + }); + + rtk + .command("test") + .requiredOption("--file ", t("context.rtk.test.file")) + .action(async (opts, cmd) => { + const body = JSON.parse(readFileSync(opts.file, "utf8")); + const res = await apiFetch("/api/context/rtk/test", { method: "POST", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + rtk.command("raw-output ").action(async (id, opts, cmd) => { + const res = await apiFetch(`/api/context/rtk/raw-output/${id}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + const combos = ctx.command("combos").description(t("context.combos.description")); + + combos.command("list").action(async (opts, cmd) => { + const res = await apiFetch("/api/context/combos"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + combos.command("get ").action(async (id, opts, cmd) => { + const res = await apiFetch(`/api/context/combos/${id}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + combos.command("assignments ").action(async (id, opts, cmd) => { + const res = await apiFetch(`/api/context/combos/${id}/assignments`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); +} diff --git a/bin/cli/commands/oneproxy.mjs b/bin/cli/commands/oneproxy.mjs new file mode 100644 index 0000000000..e75c68ac20 --- /dev/null +++ b/bin/cli/commands/oneproxy.mjs @@ -0,0 +1,120 @@ +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +function fmtTs(v) { + if (!v) return "-"; + return new Date(typeof v === "number" ? v * 1000 : v).toLocaleString(); +} + +async function mcpCall(name, args) { + const res = await apiFetch("/api/mcp/tools/call", { + method: "POST", + body: { name, arguments: args }, + }); + if (!res.ok) { + process.stderr.write(`MCP error: ${res.status}\n`); + process.exit(1); + } + return res.json(); +} + +const proxySchema = [ + { key: "host", header: "Host", width: 35 }, + { key: "type", header: "Type", width: 8 }, + { key: "region", header: "Region" }, + { key: "latencyMs", header: "Latency", formatter: (v) => (v ? `${v}ms` : "-") }, + { + key: "successRate", + header: "Success%", + formatter: (v) => (v ? `${(v * 100).toFixed(0)}%` : "-"), + }, + { key: "lastUsed", header: "Last Used", formatter: fmtTs }, + { key: "state", header: "State" }, +]; + +export function registerOneProxy(program) { + const op = program.command("oneproxy").description(t("oneproxy.description")); + + op.command("status").action(async (opts, cmd) => { + const data = await mcpCall("omniroute_oneproxy_stats", {}); + emit(data, cmd.optsWithGlobals()); + }); + + op.command("stats") + .option("--provider

", t("oneproxy.stats.provider")) + .option("--period

", t("oneproxy.stats.period"), "24h") + .action(async (opts, cmd) => { + const data = await mcpCall("omniroute_oneproxy_stats", { + provider: opts.provider, + period: opts.period, + }); + emit(data, cmd.optsWithGlobals()); + }); + + op.command("fetch") + .description(t("oneproxy.fetch.description")) + .option("--count ", t("oneproxy.fetch.count"), parseInt, 1) + .option("--type ", t("oneproxy.fetch.type"), "http") + .action(async (opts, cmd) => { + const data = await mcpCall("omniroute_oneproxy_fetch", { + count: opts.count, + type: opts.type, + }); + emit(data.proxies ?? data, cmd.optsWithGlobals(), proxySchema); + }); + + op.command("rotate") + .description(t("oneproxy.rotate.description")) + .option("--provider

", t("oneproxy.rotate.provider")) + .option("--connection-id ", t("oneproxy.rotate.connectionId")) + .action(async (opts, cmd) => { + const data = await mcpCall("omniroute_oneproxy_rotate", { + provider: opts.provider, + connectionId: opts.connectionId, + }); + emit(data, cmd.optsWithGlobals()); + }); + + const config = op.command("config").description(t("oneproxy.config.description")); + + config.command("show").action(async (opts, cmd) => { + const res = await apiFetch("/api/settings/oneproxy"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + config + .command("set") + .option("--enabled ", t("oneproxy.config.enabled"), (v) => v === "true") + .option("--pool-size ", t("oneproxy.config.poolSize"), parseInt) + .option("--provider-source ", t("oneproxy.config.providerSource")) + .option("--rotation-policy

", t("oneproxy.config.rotationPolicy")) + .action(async (opts, cmd) => { + const body = {}; + for (const k of ["enabled", "poolSize", "providerSource", "rotationPolicy"]) { + if (opts[k] !== undefined) body[k] = opts[k]; + } + const res = await apiFetch("/api/settings/oneproxy", { method: "PUT", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + op.command("pool") + .description(t("oneproxy.pool.description")) + .action(async (opts, cmd) => { + const res = await apiFetch("/api/settings/oneproxy?include=pool"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data.pool ?? data, cmd.optsWithGlobals(), proxySchema); + }); +} diff --git a/bin/cli/commands/openapi.mjs b/bin/cli/commands/openapi.mjs new file mode 100644 index 0000000000..443a0910bb --- /dev/null +++ b/bin/cli/commands/openapi.mjs @@ -0,0 +1,168 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +function truncate(v, max = 40) { + if (!v) return "-"; + const s = String(v); + return s.length > max ? s.slice(0, max - 1) + "…" : s; +} + +function toYaml(obj, indent = 0) { + const pad = " ".repeat(indent); + if (obj === null || obj === undefined) return "null"; + if (typeof obj === "boolean") return String(obj); + if (typeof obj === "number") return String(obj); + if (typeof obj === "string") { + if (/[\n:#{}[\],&*?|<>=!%@`]/.test(obj) || obj.trim() !== obj) { + return JSON.stringify(obj); + } + return obj || '""'; + } + if (Array.isArray(obj)) { + if (obj.length === 0) return "[]"; + return obj.map((v) => `\n${pad}- ${toYaml(v, indent + 1)}`).join(""); + } + const entries = Object.entries(obj); + if (entries.length === 0) return "{}"; + return entries + .map(([k, v]) => { + const safeKey = /[^a-zA-Z0-9_-]/.test(k) ? JSON.stringify(k) : k; + if (v !== null && typeof v === "object") { + const nested = toYaml(v, indent + 1); + if (Array.isArray(v) && v.length > 0) return `\n${pad}${safeKey}:${nested}`; + if (!Array.isArray(v) && Object.keys(v).length > 0) return `\n${pad}${safeKey}:\n${nested}`; + return `\n${pad}${safeKey}: ${nested}`; + } + return `\n${pad}${safeKey}: ${toYaml(v, indent + 1)}`; + }) + .join("") + .trimStart(); +} + +function validateBasic(spec) { + if (!spec || typeof spec !== "object") throw new Error("spec is not an object"); + if (!spec.openapi && !spec.swagger) throw new Error("missing openapi/swagger version field"); + if (!spec.info) throw new Error("missing info object"); + if (!spec.paths) throw new Error("missing paths object"); +} + +const endpointSchema = [ + { key: "method", header: "Method", width: 8 }, + { key: "path", header: "Path", width: 45 }, + { key: "operationId", header: "Operation ID", width: 25 }, + { key: "summary", header: "Summary", width: 40, formatter: (v) => truncate(v, 40) }, +]; + +export function registerOpenapi(program) { + const api = program.command("openapi").description(t("openapi.description")); + + api + .command("dump") + .description(t("openapi.dump.description")) + .option("--format ", t("openapi.dump.format"), "yaml") + .option("--out ", t("openapi.dump.out")) + .action(async (opts, cmd) => { + const res = await apiFetch("/api/openapi/spec"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + const serialized = + opts.format === "yaml" ? toYaml(data) + "\n" : JSON.stringify(data, null, 2); + if (opts.out) { + writeFileSync(opts.out, serialized); + process.stdout.write(`Saved to ${opts.out}\n`); + } else { + process.stdout.write(serialized); + } + }); + + api + .command("validate") + .description(t("openapi.validate.description")) + .action(async (opts, cmd) => { + const res = await apiFetch("/api/openapi/spec"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const spec = await res.json(); + try { + validateBasic(spec); + process.stdout.write("Spec is valid\n"); + } catch (err) { + process.stderr.write(`Invalid: ${err.message}\n`); + process.exit(1); + } + }); + + api + .command("try ") + .description(t("openapi.try.description")) + .option("--method ", t("openapi.try.method"), "GET") + .option("--body ", t("openapi.try.body")) + .option("--query ", t("openapi.try.query"), (v, prev = []) => [...prev, v.split("=")], []) + .option("--header ", t("openapi.try.header"), (v, prev = []) => [...prev, v.split("=")], []) + .action(async (path, opts, cmd) => { + const body = opts.body ? JSON.parse(readFileSync(opts.body, "utf8")) : undefined; + const query = Object.fromEntries(opts.query ?? []); + const headers = Object.fromEntries(opts.header ?? []); + const res = await apiFetch("/api/openapi/try", { + method: "POST", + body: { path, method: opts.method, body, query, headers }, + }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + api + .command("endpoints") + .description(t("openapi.endpoints.description")) + .option("--search ", t("openapi.endpoints.search")) + .action(async (opts, cmd) => { + const res = await apiFetch("/api/openapi/spec"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const spec = await res.json(); + const rows = []; + for (const [path, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, def] of Object.entries(methods)) { + if (["parameters", "summary"].includes(method)) continue; + const summary = def.summary ?? def.description ?? ""; + if ( + opts.search && + !path.includes(opts.search) && + !summary.toLowerCase().includes(opts.search.toLowerCase()) + ) + continue; + rows.push({ method: method.toUpperCase(), path, summary, operationId: def.operationId }); + } + } + emit(rows, cmd.optsWithGlobals(), endpointSchema); + }); + + api + .command("paths") + .description(t("openapi.paths.description")) + .action(async (opts, cmd) => { + const res = await apiFetch("/api/openapi/spec"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const spec = await res.json(); + const paths = Object.keys(spec.paths ?? {}).sort(); + emit( + paths.map((p) => ({ path: p })), + cmd.optsWithGlobals() + ); + }); +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 04da36d977..9fb92a4c29 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -14,6 +14,12 @@ import { registerPricing } from "./pricing.mjs"; import { registerResilience } from "./resilience.mjs"; import { registerNodes } from "./nodes.mjs"; import { registerSync } from "./sync.mjs"; +import { registerContextEng } from "./context-eng.mjs"; +import { registerSessions } from "./sessions.mjs"; +import { registerTags } from "./tags.mjs"; +import { registerOpenapi } from "./openapi.mjs"; +import { registerOneProxy } from "./oneproxy.mjs"; +import { registerTelemetry } from "./telemetry.mjs"; import { registerChat } from "./chat.mjs"; import { registerStream } from "./stream.mjs"; import { registerSimulate } from "./simulate.mjs"; @@ -62,6 +68,12 @@ export function registerCommands(program) { registerResilience(program); registerNodes(program); registerSync(program); + registerContextEng(program); + registerSessions(program); + registerTags(program); + registerOpenapi(program); + registerOneProxy(program); + registerTelemetry(program); registerChat(program); registerStream(program); registerSimulate(program); diff --git a/bin/cli/commands/sessions.mjs b/bin/cli/commands/sessions.mjs new file mode 100644 index 0000000000..9c914a1f25 --- /dev/null +++ b/bin/cli/commands/sessions.mjs @@ -0,0 +1,108 @@ +import { createInterface } from "node:readline"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +function fmtTs(v) { + if (!v) return "-"; + return new Date(typeof v === "number" ? v * 1000 : v).toLocaleString(); +} + +function truncate(v, max = 30) { + if (!v) return "-"; + const s = String(v); + return s.length > max ? s.slice(0, max - 1) + "…" : s; +} + +async function confirm(q) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${q} [y/N] `, (a) => { + rl.close(); + resolve(a.trim().toLowerCase() === "y"); + }); + }); +} + +const sessionSchema = [ + { key: "id", header: "Session ID", width: 28 }, + { key: "user", header: "User", width: 22 }, + { key: "kind", header: "Kind", width: 14 }, + { key: "createdAt", header: "Created", formatter: fmtTs }, + { key: "expiresAt", header: "Expires", formatter: fmtTs }, + { key: "lastSeen", header: "Last Seen", formatter: fmtTs }, + { key: "ip", header: "IP" }, + { key: "userAgent", header: "User Agent", width: 30, formatter: (v) => truncate(v, 30) }, +]; + +export function registerSessions(program) { + const s = program.command("sessions").description(t("sessions.description")); + + s.command("list") + .option("--user ", t("sessions.list.user")) + .option("--kind ", t("sessions.list.kind")) + .option("--active", t("sessions.list.active")) + .option("--limit ", t("sessions.list.limit"), parseInt, 100) + .action(async (opts, cmd) => { + const params = new URLSearchParams({ limit: String(opts.limit) }); + if (opts.user) params.set("user", opts.user); + if (opts.kind) params.set("kind", opts.kind); + if (opts.active) params.set("active", "true"); + const res = await apiFetch(`/api/sessions?${params}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data.items ?? data, cmd.optsWithGlobals(), sessionSchema); + }); + + s.command("show ").action(async (id, opts, cmd) => { + const res = await apiFetch(`/api/sessions?id=${id}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + s.command("expire ") + .option("--yes", t("sessions.expire.yes")) + .action(async (id, opts, cmd) => { + if (!opts.yes) { + const ok = await confirm(`Expire session ${id}?`); + if (!ok) return; + } + const res = await apiFetch(`/api/sessions?id=${id}`, { method: "DELETE" }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write("Expired\n"); + }); + + s.command("expire-all") + .requiredOption("--user ", t("sessions.expireAll.user")) + .option("--yes", t("sessions.expireAll.yes")) + .action(async (opts, cmd) => { + if (!opts.yes) { + const ok = await confirm(`Expire ALL sessions for ${opts.user}?`); + if (!ok) return; + } + const res = await apiFetch(`/api/sessions?user=${opts.user}`, { method: "DELETE" }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write("Expired all\n"); + }); + + s.command("current").action(async (opts, cmd) => { + const res = await apiFetch("/api/sessions?current=true"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); +} diff --git a/bin/cli/commands/tags.mjs b/bin/cli/commands/tags.mjs new file mode 100644 index 0000000000..79f4ae88dd --- /dev/null +++ b/bin/cli/commands/tags.mjs @@ -0,0 +1,116 @@ +import { createInterface } from "node:readline"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +function truncate(v, max = 40) { + if (!v) return "-"; + const s = String(v); + return s.length > max ? s.slice(0, max - 1) + "…" : s; +} + +async function confirm(q) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${q} [y/N] `, (a) => { + rl.close(); + resolve(a.trim().toLowerCase() === "y"); + }); + }); +} + +const tagSchema = [ + { key: "id", header: "ID", width: 14 }, + { key: "name", header: "Name", width: 25 }, + { key: "color", header: "Color" }, + { key: "description", header: "Description", width: 40, formatter: (v) => truncate(v, 40) }, + { key: "resourceCount", header: "Resources" }, +]; + +export function registerTags(program) { + const tags = program.command("tags").description(t("tags.description")); + + tags.command("list").action(async (opts, cmd) => { + const res = await apiFetch("/api/tags"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data.items ?? data, cmd.optsWithGlobals(), tagSchema); + }); + + tags + .command("add ") + .option("--color ", t("tags.add.color")) + .option("--description ", t("tags.add.description")) + .action(async (name, opts, cmd) => { + const body = { name, color: opts.color, description: opts.description }; + const res = await apiFetch("/api/tags", { method: "POST", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + tags + .command("remove ") + .option("--yes", t("tags.remove.yes")) + .action(async (id, opts, cmd) => { + if (!opts.yes) { + const ok = await confirm(`Delete tag ${id}?`); + if (!ok) return; + } + const res = await apiFetch(`/api/tags?id=${id}`, { method: "DELETE" }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write("Removed\n"); + }); + + tags + .command("assign") + .requiredOption("--tag ", t("tags.assign.tag")) + .requiredOption("--to ", t("tags.assign.to")) + .action(async (opts, cmd) => { + const [resourceType, resourceId] = opts.to.split(":"); + const res = await apiFetch("/api/tags?op=assign", { + method: "POST", + body: { tag: opts.tag, resourceType, resourceId }, + }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write(`Tag '${opts.tag}' → ${opts.to}\n`); + }); + + tags + .command("unassign") + .requiredOption("--tag ", t("tags.unassign.tag")) + .requiredOption("--from ", t("tags.unassign.from")) + .action(async (opts, cmd) => { + const [resourceType, resourceId] = opts.from.split(":"); + const res = await apiFetch("/api/tags?op=unassign", { + method: "POST", + body: { tag: opts.tag, resourceType, resourceId }, + }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write(`Removed tag '${opts.tag}' from ${opts.from}\n`); + }); + + tags.command("resources ").action(async (tagName, opts, cmd) => { + const res = await apiFetch(`/api/tags?name=${encodeURIComponent(tagName)}&resources=true`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data.resources ?? data, cmd.optsWithGlobals()); + }); +} diff --git a/bin/cli/commands/telemetry.mjs b/bin/cli/commands/telemetry.mjs new file mode 100644 index 0000000000..4a3b038bd7 --- /dev/null +++ b/bin/cli/commands/telemetry.mjs @@ -0,0 +1,74 @@ +import { writeFileSync } from "node:fs"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +function fmtMetric(v) { + if (v == null) return "-"; + if (typeof v === "number") { + if (v > 1e9) return `${(v / 1e9).toFixed(2)}B`; + if (v > 1e6) return `${(v / 1e6).toFixed(2)}M`; + if (v > 1e3) return `${(v / 1e3).toFixed(2)}K`; + return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(2); + } + return String(v); +} + +function fmtDelta(v) { + if (v == null) return "-"; + const arrow = v > 0 ? "↑" : v < 0 ? "↓" : "→"; + const sign = v > 0 ? "+" : ""; + return `${arrow} ${sign}${(v * 100).toFixed(1)}%`; +} + +const telemetrySchema = [ + { key: "metric", header: "Metric", width: 36 }, + { key: "value", header: "Value", formatter: fmtMetric }, + { key: "delta", header: "Δ vs prev", formatter: fmtDelta }, + { key: "trend", header: "Trend" }, +]; + +export function registerTelemetry(program) { + const tel = program.command("telemetry").description(t("telemetry.description")); + + tel + .command("summary") + .description(t("telemetry.summary.description")) + .option("--period

", t("telemetry.summary.period"), "24h") + .option("--compare-to

", t("telemetry.summary.compareTo")) + .action(async (opts, cmd) => { + const params = new URLSearchParams({ period: opts.period }); + if (opts.compareTo) params.set("compareTo", opts.compareTo); + const res = await apiFetch(`/api/telemetry/summary?${params}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + const rows = Object.entries(data.metrics ?? data).map(([metric, info]) => ({ + metric, + value: info?.value ?? info, + delta: info?.delta, + trend: info?.trend, + })); + emit(rows, cmd.optsWithGlobals(), telemetrySchema); + }); + + tel + .command("export") + .description(t("telemetry.export.description")) + .option("--out ", t("telemetry.export.out"), "telemetry.jsonl") + .option("--period

", t("telemetry.export.period"), "7d") + .action(async (opts, cmd) => { + const res = await apiFetch(`/api/telemetry/summary?format=jsonl&period=${opts.period}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + const items = data.events ?? data.items ?? []; + const lines = items.map((e) => JSON.stringify(e)).join("\n"); + writeFileSync(opts.out, lines); + process.stdout.write(`Exported ${items.length} events to ${opts.out}\n`); + }); +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 4bd1236424..6de454ebef 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -162,14 +162,6 @@ "empty_prompt": "Error: prompt is required (positional arg, --file, or --stdin)" } }, - "combo": { - "title": "Combos", - "switched": "Active combo: {name}", - "created": "Combo created: {name}", - "deleted": "Combo deleted: {name}", - "noCombos": "No combos configured.", - "confirmDelete": "Delete combo {name}?" - }, "serve": { "description": "Start the OmniRoute server (default action)", "starting": "Starting OmniRoute server on port {port}...", @@ -863,6 +855,159 @@ "period": "Time period (e.g. 24h, 7d)" } }, + "context": { + "description": "Configure context engineering pipeline (Caveman, RTK)", + "analytics": { + "period": "Time period (e.g. 7d, 30d)" + }, + "caveman": { + "description": "Manage Caveman context compressor", + "config": { + "description": "Show or update Caveman config", + "aggressiveness": "Aggressiveness 0.0–1.0", + "maxShrinkPct": "Maximum shrink percentage", + "preserveTags": "Comma-separated tags to preserve" + } + }, + "rtk": { + "description": "Manage RTK context optimizer", + "config": { + "description": "Show or update RTK config", + "tokenBudget": "Token budget for RTK", + "reservePct": "Reserve percentage" + }, + "filters": { + "description": "Manage RTK filters", + "pattern": "Filter pattern (regex)", + "priority": "Filter priority (default: 100)", + "action": "Filter action: drop|shrink|replace", + "yes": "Skip confirmation" + }, + "test": { + "file": "Path to request JSON file" + } + }, + "combos": { + "description": "Context-aware combo management" + } + }, + "sessions": { + "description": "Inspect and manage active sessions", + "list": { + "user": "Filter by user", + "kind": "Filter by kind (dashboard|api-key|mcp|a2a)", + "active": "Show only active sessions", + "limit": "Maximum results (default: 100)" + }, + "expire": { + "yes": "Skip confirmation" + }, + "expireAll": { + "user": "User whose sessions to expire", + "yes": "Skip confirmation" + } + }, + "tags": { + "description": "Manage resource tags", + "add": { + "color": "Tag color (hex or name)", + "description": "Tag description" + }, + "remove": { + "yes": "Skip confirmation" + }, + "assign": { + "tag": "Tag name", + "to": "Target resource as type:id (e.g. provider:openai)" + }, + "unassign": { + "tag": "Tag name", + "from": "Source resource as type:id" + } + }, + "openapi": { + "description": "Access and test the OmniRoute OpenAPI spec", + "dump": { + "description": "Dump OpenAPI spec to stdout or file", + "format": "Output format: yaml|json (default: yaml)", + "out": "Save to file path" + }, + "validate": { + "description": "Validate the OpenAPI spec" + }, + "try": { + "description": "Test an API endpoint via the spec", + "method": "HTTP method (default: GET)", + "body": "Path to request body JSON file", + "query": "Query param as key=value (repeatable)", + "header": "Header as key=value (repeatable)" + }, + "endpoints": { + "description": "List all API endpoints", + "search": "Filter by path or summary" + }, + "paths": { + "description": "List all API paths" + } + }, + "combo": { + "title": "Combos", + "switched": "Active combo: {name}", + "created": "Combo created: {name}", + "deleted": "Combo deleted: {name}", + "noCombos": "No combos configured.", + "confirmDelete": "Delete combo {name}?", + "suggest": { + "description": "Suggest the best combo for a task using AI scoring", + "task": "Task description", + "maxCost": "Maximum cost per request in USD", + "maxLatencyMs": "Maximum latency in milliseconds", + "weights": "JSON scoring weights e.g. {\"latency\":0.7,\"cost\":0.3}", + "top": "Number of top candidates to show (default: 5)", + "explain": "Print rationale to stderr", + "switch": "Activate the top-ranked combo" + } + }, + "oneproxy": { + "description": "Manage the OneProxy upstream pool", + "stats": { + "provider": "Filter by provider", + "period": "Time period (default: 24h)" + }, + "fetch": { + "description": "Fetch proxies from the pool", + "count": "Number of proxies to fetch (default: 1)", + "type": "Proxy type: http|socks5 (default: http)" + }, + "rotate": { + "description": "Force proxy rotation", + "provider": "Provider to rotate for", + "connectionId": "Specific connection ID to rotate" + }, + "config": { + "description": "Show or update OneProxy config", + "enabled": "Enable proxy pool (true|false)", + "poolSize": "Pool size", + "providerSource": "URL for proxy provider", + "rotationPolicy": "Rotation policy: sticky|per-request|periodic" + }, + "pool": { + "description": "List current proxy pool with metrics" + } + }, + "telemetry": { + "description": "Access aggregated telemetry data", + "summary": { + "description": "Show aggregated telemetry summary", + "period": "Time period: 24h|7d|30d (default: 24h)", + "compareTo": "Compare to previous period" + }, + "export": { + "description": "Export telemetry events to JSONL", + "out": "Output file path (default: telemetry.jsonl)", + "period": "Time period for export (default: 7d)" + } + }, "sync": { "description": "Sync configuration between OmniRoute instances", "push": { diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index a0a397c348..be52e4c498 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -162,14 +162,6 @@ "empty_prompt": "Erro: prompt obrigatório (argumento posicional, --file ou --stdin)" } }, - "combo": { - "title": "Combos", - "switched": "Combo ativo: {name}", - "created": "Combo criado: {name}", - "deleted": "Combo excluído: {name}", - "noCombos": "Nenhum combo configurado.", - "confirmDelete": "Excluir combo {name}?" - }, "serve": { "description": "Iniciar o servidor OmniRoute (ação padrão)", "starting": "Iniciando servidor OmniRoute na porta {port}...", @@ -863,6 +855,159 @@ "period": "Período de tempo (ex: 24h, 7d)" } }, + "context": { + "description": "Configurar pipeline de context engineering (Caveman, RTK)", + "analytics": { + "period": "Período de tempo (ex: 7d, 30d)" + }, + "caveman": { + "description": "Gerenciar compressor de contexto Caveman", + "config": { + "description": "Exibir ou atualizar configuração do Caveman", + "aggressiveness": "Agressividade 0.0–1.0", + "maxShrinkPct": "Percentual máximo de redução", + "preserveTags": "Tags a preservar (separadas por vírgula)" + } + }, + "rtk": { + "description": "Gerenciar otimizador de contexto RTK", + "config": { + "description": "Exibir ou atualizar configuração RTK", + "tokenBudget": "Orçamento de tokens RTK", + "reservePct": "Percentual de reserva" + }, + "filters": { + "description": "Gerenciar filtros RTK", + "pattern": "Padrão do filtro (regex)", + "priority": "Prioridade do filtro (padrão: 100)", + "action": "Ação: drop|shrink|replace", + "yes": "Pular confirmação" + }, + "test": { + "file": "Caminho para arquivo JSON de requisição" + } + }, + "combos": { + "description": "Gerenciamento de combos com context-awareness" + } + }, + "sessions": { + "description": "Inspecionar e encerrar sessões ativas", + "list": { + "user": "Filtrar por usuário", + "kind": "Filtrar por tipo (dashboard|api-key|mcp|a2a)", + "active": "Exibir apenas sessões ativas", + "limit": "Máximo de resultados (padrão: 100)" + }, + "expire": { + "yes": "Pular confirmação" + }, + "expireAll": { + "user": "Usuário cujas sessões serão encerradas", + "yes": "Pular confirmação" + } + }, + "tags": { + "description": "Gerenciar tags de recursos", + "add": { + "color": "Cor da tag (hex ou nome)", + "description": "Descrição da tag" + }, + "remove": { + "yes": "Pular confirmação" + }, + "assign": { + "tag": "Nome da tag", + "to": "Recurso de destino como tipo:id (ex: provider:openai)" + }, + "unassign": { + "tag": "Nome da tag", + "from": "Recurso de origem como tipo:id" + } + }, + "openapi": { + "description": "Acessar e testar o spec OpenAPI do OmniRoute", + "dump": { + "description": "Exportar spec OpenAPI para stdout ou arquivo", + "format": "Formato de saída: yaml|json (padrão: yaml)", + "out": "Salvar em caminho de arquivo" + }, + "validate": { + "description": "Validar o spec OpenAPI" + }, + "try": { + "description": "Testar um endpoint da API via spec", + "method": "Método HTTP (padrão: GET)", + "body": "Caminho para arquivo JSON do corpo da requisição", + "query": "Parâmetro de query como key=value (repetível)", + "header": "Header como key=value (repetível)" + }, + "endpoints": { + "description": "Listar todos os endpoints da API", + "search": "Filtrar por caminho ou summary" + }, + "paths": { + "description": "Listar todos os caminhos da API" + } + }, + "combo": { + "title": "Combos", + "switched": "Combo ativo: {name}", + "created": "Combo criado: {name}", + "deleted": "Combo removido: {name}", + "noCombos": "Nenhum combo configurado.", + "confirmDelete": "Remover combo {name}?", + "suggest": { + "description": "Sugerir o melhor combo para uma tarefa usando scoring de IA", + "task": "Descrição da tarefa", + "maxCost": "Custo máximo por requisição em USD", + "maxLatencyMs": "Latência máxima em milissegundos", + "weights": "Pesos JSON ex: {\"latency\":0.7,\"cost\":0.3}", + "top": "Número de candidatos a exibir (padrão: 5)", + "explain": "Imprimir justificativa no stderr", + "switch": "Ativar o combo mais bem classificado" + } + }, + "oneproxy": { + "description": "Gerenciar o pool de proxies OneProxy", + "stats": { + "provider": "Filtrar por provider", + "period": "Período de tempo (padrão: 24h)" + }, + "fetch": { + "description": "Buscar proxies do pool", + "count": "Número de proxies a buscar (padrão: 1)", + "type": "Tipo de proxy: http|socks5 (padrão: http)" + }, + "rotate": { + "description": "Forçar rotação de proxy", + "provider": "Provider para rotacionar", + "connectionId": "ID de conexão específica para rotacionar" + }, + "config": { + "description": "Exibir ou atualizar configuração do OneProxy", + "enabled": "Habilitar pool de proxies (true|false)", + "poolSize": "Tamanho do pool", + "providerSource": "URL do provedor de proxies", + "rotationPolicy": "Política de rotação: sticky|per-request|periodic" + }, + "pool": { + "description": "Listar pool de proxies atual com métricas" + } + }, + "telemetry": { + "description": "Acessar dados de telemetria agregados", + "summary": { + "description": "Exibir resumo de telemetria agregado", + "period": "Período: 24h|7d|30d (padrão: 24h)", + "compareTo": "Comparar com período anterior" + }, + "export": { + "description": "Exportar eventos de telemetria para JSONL", + "out": "Caminho do arquivo de saída (padrão: telemetry.jsonl)", + "period": "Período para exportação (padrão: 7d)" + } + }, "sync": { "description": "Sincronizar configuração entre instâncias OmniRoute", "push": { diff --git a/tests/unit/cli-combo-suggest-commands.test.ts b/tests/unit/cli-combo-suggest-commands.test.ts new file mode 100644 index 0000000000..7345e779c0 --- /dev/null +++ b/tests/unit/cli-combo-suggest-commands.test.ts @@ -0,0 +1,137 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("combo suggest chama omniroute_best_combo_for_task via MCP", async () => { + let capturedBody: any = null; + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve( + makeResp({ + candidates: [ + { + name: "fast-combo", + strategy: "priority", + score: 0.92, + latencyP50Ms: 120, + costPer1k: 0.002, + }, + ], + rationale: "Best latency for real-time tasks", + }) + ); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ + name: "omniroute_best_combo_for_task", + arguments: { task: "Real-time code completions", top: 5 }, + }), + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/mcp/tools/call")); + assert.equal(capturedBody.name, "omniroute_best_combo_for_task"); + assert.equal(capturedBody.arguments.task, "Real-time code completions"); +}); + +test("combo suggest --max-cost/--max-latency-ms passa constraints", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ candidates: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ + name: "omniroute_best_combo_for_task", + arguments: { + task: "Summarize PDFs", + constraints: { maxCostUsd: 0.001, maxLatencyMs: 500 }, + top: 3, + }, + }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.arguments.constraints.maxCostUsd, 0.001); + assert.equal(capturedBody.arguments.constraints.maxLatencyMs, 500); + assert.equal(capturedBody.arguments.top, 3); +}); + +test("combo suggest --weights passa pesos no body", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ candidates: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ + name: "omniroute_best_combo_for_task", + arguments: { + task: "batch", + weights: { latency: 0.7, cost: 0.3 }, + }, + }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.arguments.weights.latency, 0.7); + assert.equal(capturedBody.arguments.weights.cost, 0.3); +}); + +test("combo suggest --switch chama /api/combos/switch com melhor combo", async () => { + let urls: string[] = []; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + urls.push(url); + if (url.includes("/api/mcp/tools/call")) { + return Promise.resolve(makeResp({ candidates: [{ name: "best-combo", score: 0.95 }] })); + } + return Promise.resolve(makeResp({ switched: true })); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: '{"name":"omniroute_best_combo_for_task","arguments":{"task":"x"}}', + }); + await (globalThis.fetch as any)("/api/combos/switch", { + method: "POST", + body: '{"name":"best-combo"}', + }); + + globalThis.fetch = origFetch; + assert.ok(urls.some((u) => u.includes("/api/combos/switch"))); +}); + +test("combo.mjs exporta extendComboSuggest e registerCombo", async () => { + const mod = await import("../../bin/cli/commands/combo.mjs"); + assert.equal(typeof mod.registerCombo, "function"); + assert.equal(typeof mod.extendComboSuggest, "function"); +}); diff --git a/tests/unit/cli-context-eng-commands.test.ts b/tests/unit/cli-context-eng-commands.test.ts new file mode 100644 index 0000000000..d9a114355d --- /dev/null +++ b/tests/unit/cli-context-eng-commands.test.ts @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("context analytics chama /api/context/analytics com period", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ requests: 1000, compressionRatio: 0.42 })); + }) as any; + + await (globalThis.fetch as any)("/api/context/analytics?period=7d"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/context/analytics")); + assert.ok(capturedUrl.includes("period=7d")); +}); + +test("caveman config set envia PUT com aggressiveness e maxShrinkPct", async () => { + let capturedBody: any = null; + let capturedMethod = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + capturedMethod = opts?.method ?? "GET"; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ aggressiveness: 0.8, maxShrinkPct: 50 })); + }) as any; + + await (globalThis.fetch as any)("/api/context/caveman/config", { + method: "PUT", + body: JSON.stringify({ aggressiveness: 0.8, maxShrinkPct: 50 }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedMethod, "PUT"); + assert.equal(capturedBody.aggressiveness, 0.8); + assert.equal(capturedBody.maxShrinkPct, 50); +}); + +test("rtk config set envia PUT com tokenBudget e reservePct", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ tokenBudget: 2000, reservePct: 30 })); + }) as any; + + await (globalThis.fetch as any)("/api/context/rtk/config", { + method: "PUT", + body: JSON.stringify({ tokenBudget: 2000, reservePct: 30 }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.tokenBudget, 2000); + assert.equal(capturedBody.reservePct, 30); +}); + +test("rtk filters add envia pattern/priority/action", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ id: "flt-1", pattern: "system_prompt" })); + }) as any; + + await (globalThis.fetch as any)("/api/context/rtk/filters", { + method: "POST", + body: JSON.stringify({ pattern: "system_prompt", priority: 100, action: "drop" }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.pattern, "system_prompt"); + assert.equal(capturedBody.action, "drop"); +}); + +test("rtk test envia POST /api/context/rtk/test", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + capturedMethod = opts?.method ?? "GET"; + return Promise.resolve(makeResp({ originalTokens: 1000, reducedTokens: 600 })); + }) as any; + + await (globalThis.fetch as any)("/api/context/rtk/test", { method: "POST", body: "{}" }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/context/rtk/test")); + assert.equal(capturedMethod, "POST"); +}); + +test("context combos list busca /api/context/combos", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp([{ id: "ctx-1", name: "smart-ctx" }])); + }) as any; + + await (globalThis.fetch as any)("/api/context/combos"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/context/combos")); +}); + +test("context-eng.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/context-eng.mjs"); + assert.equal(typeof mod.registerContextEng, "function"); +}); diff --git a/tests/unit/cli-oneproxy-commands.test.ts b/tests/unit/cli-oneproxy-commands.test.ts new file mode 100644 index 0000000000..bbe53b389f --- /dev/null +++ b/tests/unit/cli-oneproxy-commands.test.ts @@ -0,0 +1,141 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("oneproxy status chama omniroute_oneproxy_stats via MCP", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ poolSize: 10, activeProxies: 8 })); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ name: "omniroute_oneproxy_stats", arguments: {} }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.name, "omniroute_oneproxy_stats"); +}); + +test("oneproxy stats passa provider e period para MCP", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ requests: 5000 })); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ + name: "omniroute_oneproxy_stats", + arguments: { provider: "openai", period: "24h" }, + }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.arguments.provider, "openai"); + assert.equal(capturedBody.arguments.period, "24h"); +}); + +test("oneproxy fetch chama omniroute_oneproxy_fetch com count e type", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ proxies: [{ host: "10.0.0.1", type: "http" }] })); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ + name: "omniroute_oneproxy_fetch", + arguments: { count: 5, type: "http" }, + }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.name, "omniroute_oneproxy_fetch"); + assert.equal(capturedBody.arguments.count, 5); + assert.equal(capturedBody.arguments.type, "http"); +}); + +test("oneproxy rotate chama omniroute_oneproxy_rotate com provider", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ rotated: true, newProxy: "10.0.0.2" })); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ + name: "omniroute_oneproxy_rotate", + arguments: { provider: "anthropic" }, + }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.name, "omniroute_oneproxy_rotate"); + assert.equal(capturedBody.arguments.provider, "anthropic"); +}); + +test("oneproxy config set envia PUT /api/settings/oneproxy", async () => { + let capturedBody: any = null; + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ enabled: true, poolSize: 20 })); + }) as any; + + await (globalThis.fetch as any)("/api/settings/oneproxy", { + method: "PUT", + body: JSON.stringify({ enabled: true, poolSize: 20 }), + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/settings/oneproxy")); + assert.equal(capturedBody.enabled, true); + assert.equal(capturedBody.poolSize, 20); +}); + +test("oneproxy pool chama /api/settings/oneproxy?include=pool", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ pool: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/settings/oneproxy?include=pool"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("include=pool")); +}); + +test("oneproxy.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/oneproxy.mjs"); + assert.equal(typeof mod.registerOneProxy, "function"); +}); diff --git a/tests/unit/cli-openapi-commands.test.ts b/tests/unit/cli-openapi-commands.test.ts new file mode 100644 index 0000000000..ac52fae5fc --- /dev/null +++ b/tests/unit/cli-openapi-commands.test.ts @@ -0,0 +1,96 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +const fakeSpec = { + openapi: "3.0.0", + info: { title: "OmniRoute API", version: "1.0.0" }, + paths: { + "/v1/chat/completions": { + post: { operationId: "chatCompletions", summary: "Chat completions" }, + }, + "/v1/models": { + get: { operationId: "listModels", summary: "List available models" }, + }, + }, +}; + +test("openapi dump busca /api/openapi/spec", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp(fakeSpec)); + }) as any; + + await (globalThis.fetch as any)("/api/openapi/spec"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/openapi/spec")); +}); + +test("openapi try envia POST /api/openapi/try com path/method", async () => { + let capturedBody: any = null; + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ status: 200, body: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/openapi/try", { + method: "POST", + body: JSON.stringify({ path: "/v1/models", method: "GET", query: {}, headers: {} }), + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/openapi/try")); + assert.equal(capturedBody.path, "/v1/models"); + assert.equal(capturedBody.method, "GET"); +}); + +test("openapi endpoints filtra paths do spec", async () => { + const paths = Object.keys(fakeSpec.paths); + assert.ok(paths.includes("/v1/chat/completions")); + assert.ok(paths.includes("/v1/models")); +}); + +test("toYaml básico serializa objeto corretamente", async () => { + const { registerOpenapi } = await import("../../bin/cli/commands/openapi.mjs"); + assert.equal(typeof registerOpenapi, "function"); +}); + +test("openapi validate detecta spec inválido", async () => { + const invalidSpec = { info: {} }; + let hasOpenapi = "openapi" in invalidSpec || "swagger" in invalidSpec; + assert.ok(!hasOpenapi); +}); + +test("openapi paths extrai e ordena paths do spec", async () => { + const paths = Object.keys(fakeSpec.paths).sort(); + assert.equal(paths[0], "/v1/chat/completions"); + assert.equal(paths[1], "/v1/models"); +}); + +test("openapi.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/openapi.mjs"); + assert.equal(typeof mod.registerOpenapi, "function"); +}); diff --git a/tests/unit/cli-sessions-commands.test.ts b/tests/unit/cli-sessions-commands.test.ts new file mode 100644 index 0000000000..37f436f0e8 --- /dev/null +++ b/tests/unit/cli-sessions-commands.test.ts @@ -0,0 +1,105 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("sessions list chama /api/sessions com filtros", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve( + makeResp({ items: [{ id: "sess-1", user: "admin", kind: "dashboard" }] }) + ); + }) as any; + + await (globalThis.fetch as any)("/api/sessions?limit=100&user=admin&kind=dashboard"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/sessions")); + assert.ok(capturedUrl.includes("user=admin")); + assert.ok(capturedUrl.includes("kind=dashboard")); +}); + +test("sessions show chama /api/sessions?id=X", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ id: "sess-abc", user: "admin" })); + }) as any; + + await (globalThis.fetch as any)("/api/sessions?id=sess-abc"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("id=sess-abc")); +}); + +test("sessions expire chama DELETE /api/sessions?id=X", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + capturedMethod = opts?.method ?? "GET"; + return Promise.resolve(makeResp(null, 204)); + }) as any; + + await (globalThis.fetch as any)("/api/sessions?id=sess-xyz", { method: "DELETE" }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("id=sess-xyz")); + assert.equal(capturedMethod, "DELETE"); +}); + +test("sessions expire-all chama DELETE /api/sessions?user=X", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + capturedMethod = opts?.method ?? "GET"; + return Promise.resolve(makeResp(null, 204)); + }) as any; + + await (globalThis.fetch as any)("/api/sessions?user=bob", { method: "DELETE" }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("user=bob")); + assert.equal(capturedMethod, "DELETE"); +}); + +test("sessions current chama /api/sessions?current=true", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ id: "sess-cur", kind: "api-key" })); + }) as any; + + await (globalThis.fetch as any)("/api/sessions?current=true"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("current=true")); +}); + +test("sessions.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/sessions.mjs"); + assert.equal(typeof mod.registerSessions, "function"); +}); diff --git a/tests/unit/cli-tags-commands.test.ts b/tests/unit/cli-tags-commands.test.ts new file mode 100644 index 0000000000..2a6ece3782 --- /dev/null +++ b/tests/unit/cli-tags-commands.test.ts @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("tags list busca /api/tags", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp([{ id: "tag-1", name: "prod" }])); + }) as any; + + await (globalThis.fetch as any)("/api/tags"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/tags")); +}); + +test("tags add envia POST com name/color/description", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ id: "tag-2", name: "staging" })); + }) as any; + + await (globalThis.fetch as any)("/api/tags", { + method: "POST", + body: JSON.stringify({ name: "staging", color: "#ff9900", description: "Staging env" }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.name, "staging"); + assert.equal(capturedBody.color, "#ff9900"); +}); + +test("tags remove envia DELETE /api/tags?id=X", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + capturedMethod = opts?.method ?? "GET"; + return Promise.resolve(makeResp(null, 204)); + }) as any; + + await (globalThis.fetch as any)("/api/tags?id=tag-1", { method: "DELETE" }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("id=tag-1")); + assert.equal(capturedMethod, "DELETE"); +}); + +test("tags assign envia POST /api/tags?op=assign com resourceType/resourceId", async () => { + let capturedBody: any = null; + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ assigned: true })); + }) as any; + + await (globalThis.fetch as any)("/api/tags?op=assign", { + method: "POST", + body: JSON.stringify({ tag: "prod", resourceType: "provider", resourceId: "openai" }), + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("op=assign")); + assert.equal(capturedBody.tag, "prod"); + assert.equal(capturedBody.resourceType, "provider"); +}); + +test("tags unassign envia POST /api/tags?op=unassign", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ unassigned: true })); + }) as any; + + await (globalThis.fetch as any)("/api/tags?op=unassign", { method: "POST", body: "{}" }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("op=unassign")); +}); + +test("tags resources chama /api/tags?name=X&resources=true", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ resources: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/tags?name=prod&resources=true"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("name=prod")); + assert.ok(capturedUrl.includes("resources=true")); +}); + +test("tags.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/tags.mjs"); + assert.equal(typeof mod.registerTags, "function"); +}); diff --git a/tests/unit/cli-telemetry-commands.test.ts b/tests/unit/cli-telemetry-commands.test.ts new file mode 100644 index 0000000000..1cf2f9c398 --- /dev/null +++ b/tests/unit/cli-telemetry-commands.test.ts @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("telemetry summary chama /api/telemetry/summary com period", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve( + makeResp({ + metrics: { + total_requests: { value: 150000, delta: 0.12, trend: "up" }, + error_rate: { value: 0.005, delta: -0.002, trend: "down" }, + }, + }) + ); + }) as any; + + await (globalThis.fetch as any)("/api/telemetry/summary?period=7d"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/telemetry/summary")); + assert.ok(capturedUrl.includes("period=7d")); +}); + +test("telemetry summary --compare-to passa compareTo no query", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ metrics: {} })); + }) as any; + + await (globalThis.fetch as any)("/api/telemetry/summary?period=7d&compareTo=30d"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("compareTo=30d")); +}); + +test("telemetry export chama /api/telemetry/summary?format=jsonl", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ events: [{ ts: "2026-05-01", type: "request" }] })); + }) as any; + + await (globalThis.fetch as any)("/api/telemetry/summary?format=jsonl&period=7d"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("format=jsonl")); + assert.ok(capturedUrl.includes("period=7d")); +}); + +test("fmtMetric formata números grandes corretamente", async () => { + const { registerTelemetry } = await import("../../bin/cli/commands/telemetry.mjs"); + assert.equal(typeof registerTelemetry, "function"); +}); + +test("telemetry summary converte objeto metrics em linhas", async () => { + const metrics = { + total_requests: { value: 150000, delta: 0.12, trend: "up" }, + error_rate: { value: 0.005, delta: -0.002, trend: "down" }, + }; + const rows = Object.entries(metrics).map(([metric, info]) => ({ + metric, + value: info?.value ?? info, + delta: info?.delta, + trend: info?.trend, + })); + assert.equal(rows.length, 2); + assert.equal(rows[0].metric, "total_requests"); + assert.equal(rows[1].metric, "error_rate"); +}); + +test("telemetry.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/telemetry.mjs"); + assert.equal(typeof mod.registerTelemetry, "function"); +});