mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cli): adicionar comando chat one-shot (Fase 2.1)
Implementa `omniroute chat [prompt]` com suporte a --file, --stdin, --system, --model, --max-tokens, --temperature, --top-p, --reasoning-effort, --thinking-budget, --combo, --responses-api, --stream e --no-history. Respostas impressas no stdout; latência e token count no stderr (não interfere em pipes). Histórico salvo em ~/.omniroute/cli-history.jsonl. Streaming via SSE com print incremental de deltas.
This commit is contained in:
162
bin/cli/commands/chat.mjs
Normal file
162
bin/cli/commands/chat.mjs
Normal file
@@ -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 <path>", t("chat.file"))
|
||||
.option("--stdin", t("chat.stdin"))
|
||||
.option("-s, --system <prompt>", t("chat.system"))
|
||||
.option("-m, --model <id>", t("chat.model"), "auto")
|
||||
.option("--max-tokens <n>", t("chat.max_tokens"), parseInt)
|
||||
.option("--temperature <t>", t("chat.temperature"), parseFloat)
|
||||
.option("--top-p <p>", t("chat.top_p"), parseFloat)
|
||||
.option("--reasoning-effort <level>", t("chat.reasoning_effort"))
|
||||
.option("--thinking-budget <tokens>", t("chat.thinking_budget"), parseInt)
|
||||
.option("--combo <name>", 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
168
tests/unit/cli-chat.test.ts
Normal file
168
tests/unit/cli-chat.test.ts
Normal file
@@ -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<void>): Promise<string> {
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user