diff --git a/bin/cli/commands/chat.mjs b/bin/cli/commands/chat.mjs new file mode 100644 index 0000000000..f5d6ba6006 --- /dev/null +++ b/bin/cli/commands/chat.mjs @@ -0,0 +1,162 @@ +import { appendFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; +import { resolveDataDir } from "../data-dir.mjs"; + +function resolveHistoryPath() { + return join(resolveDataDir(), "cli-history.jsonl"); +} + +export function registerChat(program) { + program + .command("chat [prompt]") + .description(t("chat.description")) + .option("--file ", t("chat.file")) + .option("--stdin", t("chat.stdin")) + .option("-s, --system ", t("chat.system")) + .option("-m, --model ", t("chat.model"), "auto") + .option("--max-tokens ", t("chat.max_tokens"), parseInt) + .option("--temperature ", t("chat.temperature"), parseFloat) + .option("--top-p

", t("chat.top_p"), parseFloat) + .option("--reasoning-effort ", t("chat.reasoning_effort")) + .option("--thinking-budget ", t("chat.thinking_budget"), parseInt) + .option("--combo ", t("chat.combo")) + .option("--responses-api", t("chat.responses_api")) + .option("--stream", t("chat.stream")) + .option("--no-history", t("chat.no_history")) + .action(runChatCommand); +} + +export async function runChatCommand(promptArg, opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const prompt = await resolvePrompt(promptArg, opts); + if (!prompt) { + process.stderr.write(t("chat.error.empty_prompt") + "\n"); + process.exit(2); + } + + const messages = []; + if (opts.system) messages.push({ role: "system", content: opts.system }); + messages.push({ role: "user", content: prompt }); + + const body = { + model: opts.model, + messages, + ...(opts.maxTokens && { max_tokens: opts.maxTokens }), + ...(opts.temperature != null && { temperature: opts.temperature }), + ...(opts.topP != null && { top_p: opts.topP }), + ...(opts.reasoningEffort && { reasoning_effort: opts.reasoningEffort }), + ...(opts.thinkingBudget && { thinking: { budget_tokens: opts.thinkingBudget } }), + ...(opts.combo && { combo: opts.combo }), + stream: !!opts.stream, + }; + + const endpoint = opts.responsesApi ? "/v1/responses" : "/v1/chat/completions"; + + const startedAt = Date.now(); + const response = await apiFetch(endpoint, { + method: "POST", + body, + acceptNotOk: true, + timeout: globalOpts.timeout, + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + process.stderr.write(`\x1b[31m✖ ${response.status} ${response.statusText}\x1b[0m\n`); + if (errText) process.stderr.write(errText + "\n"); + process.exit(1); + } + + const latencyMs = Date.now() - startedAt; + + if (opts.stream) { + return streamHandle(response, opts.responsesApi); + } + + const data = await response.json(); + const text = extractText(data, opts.responsesApi); + + if (!opts.noHistory) { + appendHistory({ prompt, model: opts.model, latencyMs, usage: data.usage, response: text }); + } + + if (globalOpts.output === "json") { + emit(data, globalOpts); + } else if (globalOpts.output === "markdown") { + console.log( + `# Response\n\n${text}\n\n## Metadata\n- Model: ${data.model}\n- Latency: ${latencyMs}ms\n- Usage: ${JSON.stringify(data.usage)}\n` + ); + } else { + console.log(text); + if (!globalOpts.quiet) { + process.stderr.write( + `\n[${data.model} · ${latencyMs}ms · ${data.usage?.total_tokens ?? "?"} tok]\n` + ); + } + } +} + +async function resolvePrompt(arg, opts) { + if (opts.file) return readFileSync(opts.file, "utf8").trim(); + if (opts.stdin) return readStdin(); + return arg?.trim() || ""; +} + +function readStdin() { + return new Promise((resolve) => { + let buf = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (buf += c)); + process.stdin.on("end", () => resolve(buf.trim())); + }); +} + +function extractText(data, isResponses) { + if (isResponses) { + return data.output?.[0]?.content?.[0]?.text ?? data.output_text ?? ""; + } + return data.choices?.[0]?.message?.content ?? ""; +} + +async function streamHandle(response, isResponses) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const chunk = line.slice(6).trim(); + if (chunk === "[DONE]") { + process.stdout.write("\n"); + return; + } + try { + const obj = JSON.parse(chunk); + const content = isResponses ? obj.delta?.content : obj.choices?.[0]?.delta?.content; + if (content) process.stdout.write(content); + } catch {} + } + } + process.stdout.write("\n"); +} + +function appendHistory(entry) { + try { + appendFileSync( + resolveHistoryPath(), + JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n" + ); + } catch { + // history write failures are non-fatal + } +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 0d999c3008..67f083c6ae 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -1,3 +1,4 @@ +import { registerChat } from "./chat.mjs"; import { registerServe } from "./serve.mjs"; import { registerStop } from "./stop.mjs"; import { registerRestart } from "./restart.mjs"; @@ -25,6 +26,7 @@ import { registerTestProvider } from "./test-provider.mjs"; import { registerCompletion } from "./completion.mjs"; export function registerCommands(program) { + registerChat(program); registerServe(program); registerStop(program); registerRestart(program); diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index f4f55306ec..e654d8acd2 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -54,6 +54,25 @@ "noKeys": "No keys configured.", "confirmRemove": "Remove key {id}?" }, + "chat": { + "description": "Send a one-shot chat prompt to OmniRoute", + "file": "Read prompt from file", + "stdin": "Read prompt from stdin", + "system": "System prompt", + "model": "Model ID (default: auto)", + "max_tokens": "Maximum tokens in response", + "temperature": "Sampling temperature (0–2)", + "top_p": "Top-p nucleus sampling", + "reasoning_effort": "Reasoning effort level (low|medium|high)", + "thinking_budget": "Extended thinking token budget", + "combo": "Force a specific combo by name", + "responses_api": "Use /v1/responses instead of /v1/chat/completions", + "stream": "Stream response incrementally", + "no_history": "Do not save to ~/.omniroute/cli-history.jsonl", + "error": { + "empty_prompt": "Error: prompt is required (positional arg, --file, or --stdin)" + } + }, "combo": { "title": "Combos", "switched": "Active combo: {name}", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index d135debc80..9e7c2510df 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -54,6 +54,25 @@ "noKeys": "Nenhuma chave configurada.", "confirmRemove": "Remover chave {id}?" }, + "chat": { + "description": "Enviar um prompt único ao OmniRoute", + "file": "Ler prompt de arquivo", + "stdin": "Ler prompt da entrada padrão", + "system": "Prompt de sistema", + "model": "ID do modelo (padrão: auto)", + "max_tokens": "Máximo de tokens na resposta", + "temperature": "Temperatura de amostragem (0–2)", + "top_p": "Amostragem nucleus top-p", + "reasoning_effort": "Nível de esforço de raciocínio (low|medium|high)", + "thinking_budget": "Budget de tokens para raciocínio estendido", + "combo": "Forçar um combo específico pelo nome", + "responses_api": "Usar /v1/responses em vez de /v1/chat/completions", + "stream": "Transmitir resposta incrementalmente", + "no_history": "Não salvar em ~/.omniroute/cli-history.jsonl", + "error": { + "empty_prompt": "Erro: prompt obrigatório (argumento posicional, --file ou --stdin)" + } + }, "combo": { "title": "Combos", "switched": "Combo ativo: {name}", diff --git a/tests/unit/cli-chat.test.ts b/tests/unit/cli-chat.test.ts new file mode 100644 index 0000000000..e6e34d38ae --- /dev/null +++ b/tests/unit/cli-chat.test.ts @@ -0,0 +1,168 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { tmpdir } from "node:os"; +import { mkdtempSync, readFileSync, existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +function mockFetch(body: unknown, status = 200) { + return () => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) + ); +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: string | Uint8Array) => { + chunks.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +const FAKE_RESPONSE = { + id: "chatcmpl-abc", + model: "claude-sonnet-4-6", + choices: [{ message: { role: "assistant", content: "Hello!" } }], + usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 }, +}; + +test("runChatCommand imprime texto da resposta no stdout", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "chat-test-")); + process.env.DATA_DIR = tmpDir; + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch(FAKE_RESPONSE) as any; + + const { runChatCommand } = await import("../../bin/cli/commands/chat.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "text", quiet: false }) }; + const out = await captureStdout(() => + runChatCommand("hi", { model: "auto", noHistory: true }, cmd as any) + ); + + globalThis.fetch = origFetch; + delete process.env.DATA_DIR; + assert.ok(out.includes("Hello!")); +}); + +test("runChatCommand com --output json emite body completo", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "chat-test-")); + process.env.DATA_DIR = tmpDir; + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch(FAKE_RESPONSE) as any; + + const { runChatCommand } = await import("../../bin/cli/commands/chat.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) }; + const out = await captureStdout(() => + runChatCommand("hi", { model: "auto", noHistory: true }, cmd as any) + ); + + globalThis.fetch = origFetch; + delete process.env.DATA_DIR; + assert.equal(JSON.parse(out).id, "chatcmpl-abc"); +}); + +test("runChatCommand lê prompt de arquivo com --file", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "chat-test-")); + const promptFile = join(tmpDir, "prompt.txt"); + writeFileSync(promptFile, "file prompt content"); + process.env.DATA_DIR = tmpDir; + + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, init: any) => { + capturedBody = JSON.parse(init.body); + return Promise.resolve(new Response(JSON.stringify(FAKE_RESPONSE), { status: 200 })); + }) as any; + + const { runChatCommand } = await import("../../bin/cli/commands/chat.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "text", quiet: true }) }; + await captureStdout(() => + runChatCommand(undefined, { model: "auto", file: promptFile, noHistory: true }, cmd as any) + ); + + globalThis.fetch = origFetch; + delete process.env.DATA_DIR; + assert.equal(capturedBody.messages[0].content, "file prompt content"); +}); + +test("runChatCommand salva histórico em cli-history.jsonl", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "chat-hist-")); + process.env.DATA_DIR = tmpDir; + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch(FAKE_RESPONSE) as any; + + const { runChatCommand } = await import("../../bin/cli/commands/chat.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "text", quiet: true }) }; + await captureStdout(() => runChatCommand("save this", { model: "auto" }, cmd as any)); + + globalThis.fetch = origFetch; + + const histPath = join(tmpDir, "cli-history.jsonl"); + assert.ok(existsSync(histPath)); + const line = JSON.parse(readFileSync(histPath, "utf8").trim().split("\n")[0]); + assert.equal(line.prompt, "save this"); + assert.ok(line.ts); + delete process.env.DATA_DIR; +}); + +test("runChatCommand usa /v1/responses com --responses-api", async () => { + const responsesBody = { + id: "resp-abc", + output: [{ content: [{ text: "Responses API response" }] }], + usage: { total_tokens: 10 }, + model: "auto", + }; + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, init: any) => { + capturedUrl = url; + return Promise.resolve(new Response(JSON.stringify(responsesBody), { status: 200 })); + }) as any; + + const tmpDir = mkdtempSync(join(tmpdir(), "chat-test-")); + process.env.DATA_DIR = tmpDir; + + const { runChatCommand } = await import("../../bin/cli/commands/chat.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "text", quiet: true }) }; + const out = await captureStdout(() => + runChatCommand("test", { model: "auto", responsesApi: true, noHistory: true }, cmd as any) + ); + + globalThis.fetch = origFetch; + delete process.env.DATA_DIR; + assert.ok(capturedUrl.includes("/v1/responses")); + assert.ok(out.includes("Responses API response")); +}); + +test("runChatCommand propaga system prompt no payload", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, init: any) => { + capturedBody = JSON.parse(init.body); + return Promise.resolve(new Response(JSON.stringify(FAKE_RESPONSE), { status: 200 })); + }) as any; + + const tmpDir = mkdtempSync(join(tmpdir(), "chat-test-")); + process.env.DATA_DIR = tmpDir; + + const { runChatCommand } = await import("../../bin/cli/commands/chat.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "text", quiet: true }) }; + await captureStdout(() => + runChatCommand("hi", { model: "auto", system: "Be concise", noHistory: true }, cmd as any) + ); + + globalThis.fetch = origFetch; + delete process.env.DATA_DIR; + assert.equal(capturedBody.messages[0].role, "system"); + assert.equal(capturedBody.messages[0].content, "Be concise"); + assert.equal(capturedBody.messages[1].content, "hi"); +});