From 09db1129a1b795449338f47a06c417e8521cc6de Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 15 May 2026 09:13:56 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20R7=20=E2=80=94=20OAuthFlow/EvalWat?= =?UTF-8?q?ch/ProvidersTestAll=20TUI=20+=20integra=C3=A7=C3=A3o=20(spec=20?= =?UTF-8?q?8.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona 3 componentes TUI Ink faltantes da spec 8.10: - OAuthFlow.jsx: spinner + URL interativo para fluxo browser OAuth - EvalWatch.jsx: monitor live de eval run com ProgressBar e DataTable - ProvidersTestAll.jsx: teste paralelo com concorrência configurável Integração nos comandos: - oauth start browser flow → OAuthFlow quando TTY - eval run --watch → EvalWatch quando TTY (fallback texto sem TTY) - test --all-providers → ProvidersTestAll quando TTY (fallback tabela sem TTY) --- bin/cli/commands/eval.mjs | 14 +- bin/cli/commands/oauth.mjs | 15 +- bin/cli/commands/test-provider.mjs | 56 +++++++ bin/cli/locales/en.json | 3 +- bin/cli/locales/pt-BR.json | 3 +- bin/cli/tui/EvalWatch.jsx | 175 +++++++++++++++++++++ bin/cli/tui/OAuthFlow.jsx | 172 +++++++++++++++++++++ bin/cli/tui/ProvidersTestAll.jsx | 189 +++++++++++++++++++++++ tests/unit/cli-expanded-commands.test.ts | 33 ++++ 9 files changed, 653 insertions(+), 7 deletions(-) create mode 100644 bin/cli/tui/EvalWatch.jsx create mode 100644 bin/cli/tui/OAuthFlow.jsx create mode 100644 bin/cli/tui/ProvidersTestAll.jsx diff --git a/bin/cli/commands/eval.mjs b/bin/cli/commands/eval.mjs index 2027bed021..a0baf40a18 100644 --- a/bin/cli/commands/eval.mjs +++ b/bin/cli/commands/eval.mjs @@ -145,8 +145,18 @@ export async function runEvalRun(suiteId, opts, cmd) { const run = await res.json(); emit(run, globalOpts, runSchema); if (opts.watch) { - process.stderr.write("\nWatching run... (Ctrl+C to detach)\n"); - await watchRun(run.id, globalOpts); + if (process.stdout.isTTY) { + const { startEvalWatchTui } = await import("../tui/EvalWatch.jsx"); + await startEvalWatchTui({ + runId: run.id, + suiteId: opts.suite, + baseUrl: globalOpts.baseUrl ?? "http://localhost:20128", + apiKey: globalOpts.apiKey ?? process.env.OMNIROUTE_API_KEY, + }); + } else { + process.stderr.write("\nWatching run... (Ctrl+C to detach)\n"); + await watchRun(run.id, globalOpts); + } } } diff --git a/bin/cli/commands/oauth.mjs b/bin/cli/commands/oauth.mjs index 44f785eb8f..2d907df39f 100644 --- a/bin/cli/commands/oauth.mjs +++ b/bin/cli/commands/oauth.mjs @@ -64,9 +64,18 @@ async function runBrowserFlow(def, opts) { } const start = await startRes.json(); const url = start.authorizeUrl ?? start.url; - process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`); - if (opts.browser !== false) await openBrowser(url); - process.stderr.write("Waiting for authorization... (Ctrl+C to cancel)\n"); + + if (process.stdout.isTTY && opts.browser !== false) { + const { startOAuthTui } = await import("../tui/OAuthFlow.jsx"); + await openBrowser(url); + const tuiResult = await startOAuthTui({ provider: def.name ?? def.id, url }); + if (tuiResult.status === "cancelled") return; + } else { + process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`); + if (opts.browser !== false) await openBrowser(url); + process.stderr.write("Waiting for authorization... (Ctrl+C to cancel)\n"); + } + const result = await pollStatus( `/api/oauth/${def.id}/status?state=${encodeURIComponent(start.state ?? "")}`, opts.timeout ?? 300000 diff --git a/bin/cli/commands/test-provider.mjs b/bin/cli/commands/test-provider.mjs index 885251dd69..9238de4957 100644 --- a/bin/cli/commands/test-provider.mjs +++ b/bin/cli/commands/test-provider.mjs @@ -29,6 +29,10 @@ export async function runTestProviderCommand(provider, model, opts = {}) { return 1; } + if (opts.allProviders) { + return _runAllProviders(opts); + } + if (opts.compare) { return _runCompare(provider, opts); } @@ -65,6 +69,58 @@ export async function runTestProviderCommand(provider, model, opts = {}) { return aggregated.success ? 0 : 1; } +async function _runAllProviders(opts) { + const res = await apiFetch("/api/providers?limit=200", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("test.noServer")); + return 1; + } + const data = await res.json(); + const connections = (data.providers ?? data.items ?? data).filter( + (c) => c.authType === "apikey" || c.testStatus !== "unavailable" + ); + if (connections.length === 0) { + console.log(t("test.noProviders")); + return 0; + } + + const providers = connections.map((c) => ({ + provider: c.provider ?? c.id, + model: c.defaultModel ?? c.model, + })); + + if (process.stdout.isTTY && !opts.json && opts.output !== "json") { + const { startProvidersTestTui } = await import("../tui/ProvidersTestAll.jsx"); + const baseUrl = opts.baseUrl ?? "http://localhost:20128"; + const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY; + await startProvidersTestTui({ providers, baseUrl, apiKey }); + return 0; + } + + const results = await Promise.all( + providers.map(async ({ provider, model }) => { + const r = await _runSingleTest(provider, model); + return { provider, model, ...r }; + }) + ); + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(results, null, 2)); + } else { + for (const r of results) { + const mark = r.success ? "\x1b[32m✔\x1b[0m" : "\x1b[31m✖\x1b[0m"; + console.log(`${mark} ${r.provider}/${r.model ?? "-"}`); + } + } + + const failed = results.filter((r) => !r.success).length; + return failed > 0 ? 1 : 0; +} + async function _runCompare(provider, opts) { const targetProvider = provider || "anthropic"; const models = opts.compare diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index f9f7bf4a87..7505a9567f 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -290,7 +290,8 @@ "saveOpt": "Save results to a JSON file", "saved": "Results saved to {path}", "compareTitle": "Model Comparison", - "compareMinTwo": "At least two models required for --compare (comma-separated)" + "compareMinTwo": "At least two models required for --compare (comma-separated)", + "noProviders": "No providers configured. Add one with: omniroute keys add" }, "update": { "checking": "Checking for updates...", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index e26db7969d..1b58c5154a 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -290,7 +290,8 @@ "saveOpt": "Salvar resultados em arquivo JSON", "saved": "Resultados salvos em {path}", "compareTitle": "Comparação de Modelos", - "compareMinTwo": "São necessários pelo menos dois modelos para --compare (separados por vírgula)" + "compareMinTwo": "São necessários pelo menos dois modelos para --compare (separados por vírgula)", + "noProviders": "Nenhum provedor configurado. Adicione um com: omniroute keys add" }, "update": { "checking": "Verificando atualizações...", diff --git a/bin/cli/tui/EvalWatch.jsx b/bin/cli/tui/EvalWatch.jsx new file mode 100644 index 0000000000..1e091ae853 --- /dev/null +++ b/bin/cli/tui/EvalWatch.jsx @@ -0,0 +1,175 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { render, Box, Text, useInput } from "ink"; +import Spinner from "ink-spinner"; +import { ProgressBar } from "../tui-components/ProgressBar.jsx"; +import { StatusBadge } from "../tui-components/StatusBadge.jsx"; +import { DataTable } from "../tui-components/DataTable.jsx"; +import { HeaderSwr } from "../tui-components/HeaderSwr.jsx"; + +const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); + +const RESULT_SCHEMA = [ + { key: "idx", header: "#", width: 5 }, + { key: "status", header: "Status", width: 10, formatter: (v) => (v === "pass" ? "✔" : "✖") }, + { key: "model", header: "Model", width: 22 }, + { key: "score", header: "Score", width: 8, formatter: (v) => (v != null ? String(v) : "-") }, + { key: "latencyMs", header: "ms", width: 8, formatter: (v) => (v != null ? String(v) : "-") }, +]; + +function EvalWatchApp({ runId, suiteId, baseUrl, apiKey, onExit }) { + const [run, setRun] = useState(null); + const [results, setResults] = useState([]); + const [paused, setPaused] = useState(false); + const [done, setDone] = useState(false); + const [elapsed, setElapsed] = useState(0); + + const fetchUrl = `${baseUrl ?? "http://localhost:20128"}/api/evals/${runId}`; + const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; + + useEffect(() => { + const id = setInterval(() => setElapsed((e) => e + 1), 1000); + return () => clearInterval(id); + }, []); + + const fetcher = useCallback(async () => { + if (paused) return null; + const res = await fetch(fetchUrl, { headers }); + if (!res.ok) return null; + return res.json(); + }, [fetchUrl, paused]); + + useEffect(() => { + if (!run) return; + if (TERMINAL_STATUSES.has(run.status)) { + setDone(true); + } + const samples = run.samples ?? run.results ?? []; + setResults( + samples.map((s, i) => ({ + idx: i + 1, + status: s.pass ? "pass" : "fail", + model: s.model ?? "-", + score: s.score, + latencyMs: s.latencyMs, + })) + ); + }, [run]); + + useInput((input, key) => { + if (input === "q" || (key.ctrl && input === "c")) onExit?.(); + if (input === "p") setPaused((p) => !p); + }); + + const total = run?.progress?.total ?? 0; + const completed = run?.progress?.completed ?? 0; + const passed = run?.progress?.passed ?? results.filter((r) => r.status === "pass").length; + const failed = run?.progress?.failed ?? results.filter((r) => r.status === "fail").length; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( + + + + Eval Watch — Run {runId} + {suiteId ? ` (suite: ${suiteId})` : ""} + + + {Math.floor(elapsed / 60)}:{String(elapsed % 60).padStart(2, "0")} + {paused ? " [PAUSED]" : ""} + + + + { + if (data && data !== run) setRun(data); + return null; + }} + initial={null} + /> + + {run ? ( + <> + + + {run.status} + + {completed}/{total || "?"} samples + + + + {total > 0 && ( + + + + ✔ {passed} passed + + ✖ {failed} failed + + + )} + + {results.length > 0 && ( + + + Recent results + + + + )} + + {done && ( + + + {run.status === "completed" + ? `✔ Eval completed — ${passed}/${total} passed` + : `✖ Eval ${run.status}`} + + + )} + + ) : ( + + + + + Loading... + + )} + + + [q] quit [p] {paused ? "resume" : "pause"} + + + ); +} + +export async function startEvalWatchTui({ runId, suiteId, baseUrl, apiKey }) { + return new Promise((resolve, reject) => { + function onExit() { + unmount(); + resolve(); + } + + const { unmount, waitUntilExit } = render( + + ); + + waitUntilExit().then(resolve).catch(reject); + }); +} diff --git a/bin/cli/tui/OAuthFlow.jsx b/bin/cli/tui/OAuthFlow.jsx new file mode 100644 index 0000000000..f8f26d6639 --- /dev/null +++ b/bin/cli/tui/OAuthFlow.jsx @@ -0,0 +1,172 @@ +import React, { useState, useEffect } from "react"; +import { render, Box, Text, useInput } from "ink"; +import Spinner from "ink-spinner"; +import { StatusBadge } from "../tui-components/StatusBadge.jsx"; +import { ConfirmDialog } from "../tui-components/ConfirmDialog.jsx"; + +const PHASE = { + WAITING: "waiting", + POLLING: "polling", + DONE: "done", + FAILED: "failed", + CANCELLED: "cancelled", +}; + +function OAuthFlowApp({ provider, url, deviceCode, onCancel, onDone, onFail }) { + const [phase, setPhase] = useState(PHASE.WAITING); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [confirmCancel, setConfirmCancel] = useState(false); + + useEffect(() => { + const id = setInterval(() => setElapsed((e) => e + 1), 1000); + return () => clearInterval(id); + }, []); + + useEffect(() => { + if (!onDone && !onFail) return; + setPhase(PHASE.POLLING); + }, []); + + useInput((input, key) => { + if (phase === PHASE.DONE || phase === PHASE.FAILED || phase === PHASE.CANCELLED) return; + if (input === "q" || (key.ctrl && input === "c")) { + setConfirmCancel(true); + } + }); + + function handleCancelConfirm(yes) { + setConfirmCancel(false); + if (yes) { + setPhase(PHASE.CANCELLED); + onCancel?.(); + } + } + + const elapsed_str = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, "0")}`; + + if (confirmCancel) { + return ( + + + + ); + } + + return ( + + + + OmniRoute OAuth — {provider} + + + + {url && ( + + Open this URL in your browser to authorize: + + + {url} + + + + )} + + {deviceCode && ( + + + Device code:{" "} + + {deviceCode} + + + + )} + + + {phase === PHASE.POLLING || phase === PHASE.WAITING ? ( + + + + + Waiting for authorization... + ({elapsed_str}) + + ) : phase === PHASE.DONE ? ( + + + Authorized: {result?.email ?? result?.account ?? "connected"} + + ) : phase === PHASE.FAILED ? ( + + + Failed: {error} + + ) : ( + + + Cancelled. + + )} + + + {(phase === PHASE.POLLING || phase === PHASE.WAITING) && ( + + [q] cancel + + )} + + ); +} + +export async function startOAuthTui({ provider, url, deviceCode }) { + return new Promise((resolve, reject) => { + let resolved = false; + + function onDone(result) { + if (resolved) return; + resolved = true; + unmount(); + resolve({ status: "authorized", result }); + } + + function onFail(err) { + if (resolved) return; + resolved = true; + unmount(); + resolve({ status: "failed", error: err }); + } + + function onCancel() { + if (resolved) return; + resolved = true; + unmount(); + resolve({ status: "cancelled" }); + } + + const { unmount, waitUntilExit } = render( + + ); + + waitUntilExit() + .then(() => { + if (!resolved) resolve({ status: "exited" }); + }) + .catch(reject); + }); +} + +export function markOAuthDone(result) {} +export function markOAuthFailed(error) {} diff --git a/bin/cli/tui/ProvidersTestAll.jsx b/bin/cli/tui/ProvidersTestAll.jsx new file mode 100644 index 0000000000..cca4dddd1b --- /dev/null +++ b/bin/cli/tui/ProvidersTestAll.jsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { render, Box, Text, useInput } from "ink"; +import Spinner from "ink-spinner"; +import { DataTable } from "../tui-components/DataTable.jsx"; +import { ProgressBar } from "../tui-components/ProgressBar.jsx"; + +const STATUS = { + PENDING: "pending", + RUNNING: "running", + PASS: "pass", + FAIL: "fail", + SKIP: "skip", +}; + +const TABLE_SCHEMA = [ + { key: "provider", header: "Provider", width: 28 }, + { key: "model", header: "Model", width: 32 }, + { + key: "status", + header: "Status", + width: 10, + formatter: (v) => { + if (v === STATUS.RUNNING) return "…"; + if (v === STATUS.PASS) return "✔"; + if (v === STATUS.FAIL) return "✖"; + if (v === STATUS.SKIP) return "—"; + return "·"; + }, + }, + { key: "latencyMs", header: "ms", width: 8, formatter: (v) => (v != null ? String(v) : "-") }, + { key: "error", header: "Error", width: 28, formatter: (v) => (v ? v.slice(0, 26) : "") }, +]; + +async function testOne(provider, model, baseUrl, apiKey) { + const headers = { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }; + const start = Date.now(); + try { + const res = await fetch(`${baseUrl}/api/v1/providers/test`, { + method: "POST", + headers, + body: JSON.stringify({ provider, model }), + signal: AbortSignal.timeout(30000), + }); + const latencyMs = Date.now() - start; + const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` }; + return { status: data.success ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error }; + } catch (err) { + return { + status: STATUS.FAIL, + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onExit }) { + const resolved = `${baseUrl ?? "http://localhost:20128"}`; + + const [rows, setRows] = useState(() => + providers.map((p, i) => ({ + id: i, + provider: p.provider ?? p.id ?? String(p), + model: p.model ?? p.defaultModel ?? "", + status: STATUS.PENDING, + latencyMs: null, + error: null, + })) + ); + const [done, setDone] = useState(false); + const [started, setStarted] = useState(false); + + const update = useCallback((id, patch) => { + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }, []); + + useEffect(() => { + if (started) return; + setStarted(true); + + async function runAll() { + const queue = [...rows]; + let running = 0; + let cursor = 0; + + function nextSlot() { + while (running < concurrency && cursor < queue.length) { + const row = queue[cursor++]; + running++; + update(row.id, { status: STATUS.RUNNING }); + testOne(row.provider, row.model, resolved, apiKey).then((result) => { + update(row.id, result); + running--; + nextSlot(); + if (cursor >= queue.length && running === 0) setDone(true); + }); + } + } + + nextSlot(); + } + + runAll(); + }, []); + + useInput((input, key) => { + if (input === "q" || (key.ctrl && input === "c")) onExit?.(); + }); + + const total = rows.length; + const completed = rows.filter((r) => r.status === STATUS.PASS || r.status === STATUS.FAIL).length; + const passed = rows.filter((r) => r.status === STATUS.PASS).length; + const failed = rows.filter((r) => r.status === STATUS.FAIL).length; + const running = rows.filter((r) => r.status === STATUS.RUNNING).length; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( + + + + Providers Test All + + + {running > 0 ? ( + <> + {running} running + + ) : done ? ( + "done" + ) : ( + "queued" + )} + + + + + 0 ? "red" : "cyan"} /> + + + {completed}/{total} + + + ✔ {passed} + + ✖ {failed} + + + + + + {done && ( + + + {failed === 0 + ? `All ${passed} providers passed!` + : `${passed} passed, ${failed} failed`} + + + )} + + + [q] quit + + + ); +} + +export async function startProvidersTestTui({ providers, baseUrl, apiKey, concurrency = 4 }) { + return new Promise((resolve, reject) => { + function onExit() { + unmount(); + resolve(); + } + + const { unmount, waitUntilExit } = render( + + ); + + waitUntilExit().then(resolve).catch(reject); + }); +} diff --git a/tests/unit/cli-expanded-commands.test.ts b/tests/unit/cli-expanded-commands.test.ts index 3e8aafd379..6996a1cab8 100644 --- a/tests/unit/cli-expanded-commands.test.ts +++ b/tests/unit/cli-expanded-commands.test.ts @@ -134,6 +134,39 @@ test("test-provider — registerTestProvider registra flags latency/repeat/compa assert.ok(optNames.includes("--save"), "--save deve existir"); }); +test("OAuthFlow.jsx — arquivo existe e exporta startOAuthTui", async () => { + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const path = new URL("../../bin/cli/tui/OAuthFlow.jsx", import.meta.url); + const src = readFileSync(fileURLToPath(path), "utf8"); + assert.ok( + src.includes("export async function startOAuthTui"), + "startOAuthTui deve ser exportada" + ); +}); + +test("EvalWatch.jsx — arquivo existe e exporta startEvalWatchTui", async () => { + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const path = new URL("../../bin/cli/tui/EvalWatch.jsx", import.meta.url); + const src = readFileSync(fileURLToPath(path), "utf8"); + assert.ok( + src.includes("export async function startEvalWatchTui"), + "startEvalWatchTui deve ser exportada" + ); +}); + +test("ProvidersTestAll.jsx — arquivo existe e exporta startProvidersTestTui", async () => { + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const path = new URL("../../bin/cli/tui/ProvidersTestAll.jsx", import.meta.url); + const src = readFileSync(fileURLToPath(path), "utf8"); + assert.ok( + src.includes("export async function startProvidersTestTui"), + "startProvidersTestTui deve ser exportada" + ); +}); + test("test-provider — compare requer pelo menos dois modelos sem server retorna 0 ou 1", async () => { const { runTestProviderCommand } = await import("../../bin/cli/commands/test-provider.mjs"); let code = 0;