diff --git a/bin/cli/commands/pricing.mjs b/bin/cli/commands/pricing.mjs new file mode 100644 index 0000000000..69db1a7c61 --- /dev/null +++ b/bin/cli/commands/pricing.mjs @@ -0,0 +1,123 @@ +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 fmtPrice(v) { + if (v == null) return "-"; + return `$${Number(v).toFixed(2)}`; +} + +const pricingSchema = [ + { key: "model", header: "Model", width: 32 }, + { key: "provider", header: "Provider", width: 16 }, + { key: "inputPer1M", header: "Input/$1M", formatter: fmtPrice }, + { key: "outputPer1M", header: "Output/$1M", formatter: fmtPrice }, + { key: "cacheReadPer1M", header: "Cache R", formatter: fmtPrice }, + { key: "cacheWritePer1M", header: "Cache W", formatter: fmtPrice }, + { key: "source", header: "Source" }, + { key: "updatedAt", header: "Updated", formatter: fmtTs }, +]; + +export async function runPricingSync(opts, cmd) { + const res = await apiFetch("/api/pricing/sync", { + method: "POST", + body: { provider: opts.provider, force: !!opts.force }, + }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); +} + +export async function runPricingList(opts, cmd) { + const params = new URLSearchParams({ limit: String(opts.limit ?? 200) }); + if (opts.provider) params.set("provider", opts.provider); + if (opts.model) params.set("model", opts.model); + const res = await apiFetch(`/api/pricing?${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(), pricingSchema); +} + +export function registerPricing(program) { + const pricing = program.command("pricing").description(t("pricing.description")); + + pricing + .command("sync") + .description(t("pricing.sync.description")) + .option("--provider

", t("pricing.sync.provider")) + .option("--force", t("pricing.sync.force")) + .action(runPricingSync); + + pricing + .command("list") + .option("--provider

", t("pricing.list.provider")) + .option("--model ", t("pricing.list.model")) + .option("--limit ", t("pricing.list.limit"), parseInt, 200) + .action(runPricingList); + + pricing.command("get ").action(async (model, opts, cmd) => { + const res = await apiFetch(`/api/pricing?model=${encodeURIComponent(model)}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + const defaults = pricing.command("defaults").description(t("pricing.defaults.description")); + + defaults.command("show").action(async (opts, cmd) => { + const res = await apiFetch("/api/pricing/defaults"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + defaults + .command("set") + .option("--input

", t("pricing.defaults.input"), parseFloat) + .option("--output

", t("pricing.defaults.output"), parseFloat) + .option("--cache-read

", t("pricing.defaults.cacheRead"), parseFloat) + .option("--cache-write

", t("pricing.defaults.cacheWrite"), parseFloat) + .action(async (opts, cmd) => { + const body = {}; + if (opts.input != null) body.inputPer1M = opts.input; + if (opts.output != null) body.outputPer1M = opts.output; + if (opts.cacheRead != null) body.cacheReadPer1M = opts.cacheRead; + if (opts.cacheWrite != null) body.cacheWritePer1M = opts.cacheWrite; + const res = await apiFetch("/api/pricing/defaults", { method: "PUT", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + pricing + .command("diff") + .description(t("pricing.diff.description")) + .option("--model ", t("pricing.diff.model")) + .action(async (opts, cmd) => { + const params = new URLSearchParams({ diff: "true" }); + if (opts.model) params.set("model", opts.model); + const res = await apiFetch(`/api/pricing?${params}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data.diff ?? data, cmd.optsWithGlobals()); + }); +} diff --git a/tests/unit/cli-pricing-commands.test.ts b/tests/unit/cli-pricing-commands.test.ts new file mode 100644 index 0000000000..885466a568 --- /dev/null +++ b/tests/unit/cli-pricing-commands.test.ts @@ -0,0 +1,146 @@ +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("runPricingSync chama POST /api/pricing/sync", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + capturedMethod = opts?.method ?? "GET"; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ updated: 42, synced: true })); + }) as any; + + const { runPricingSync } = await import("../../bin/cli/commands/pricing.mjs"); + await captureStdout(() => runPricingSync({ provider: "openai", force: true }, makeCmd() as any)); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/pricing/sync")); + assert.equal(capturedMethod, "POST"); + assert.equal(capturedBody.provider, "openai"); + assert.equal(capturedBody.force, true); +}); + +test("runPricingList busca /api/pricing com filtros", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ items: [] })); + }) as any; + + const { runPricingList } = await import("../../bin/cli/commands/pricing.mjs"); + await captureStdout(() => + runPricingList({ provider: "anthropic", model: "claude-3", limit: 50 }, makeCmd() as any) + ); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/pricing")); + assert.ok(capturedUrl.includes("provider=anthropic")); + assert.ok(capturedUrl.includes("model=claude-3")); +}); + +test("pricing get busca modelo especĂ­fico", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ model: "gpt-4o", inputPer1M: 2.5, outputPer1M: 10.0 })); + }) as any; + + await (globalThis.fetch as any)("/api/pricing?model=gpt-4o"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("model=gpt-4o")); +}); + +test("pricing defaults show busca /api/pricing/defaults", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ inputPer1M: 1.0, outputPer1M: 3.0 })); + }) as any; + + await (globalThis.fetch as any)("/api/pricing/defaults"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/pricing/defaults")); +}); + +test("pricing defaults set envia body correto", 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({ inputPer1M: 1.5, outputPer1M: 5.0 })); + }) as any; + + await (globalThis.fetch as any)("/api/pricing/defaults", { + method: "PUT", + body: JSON.stringify({ inputPer1M: 1.5, outputPer1M: 5.0 }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedMethod, "PUT"); + assert.equal(capturedBody.inputPer1M, 1.5); + assert.equal(capturedBody.outputPer1M, 5.0); +}); + +test("pricing diff passa diff=true na query", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ diff: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/pricing?diff=true"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("diff=true")); +}); + +test("pricing.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/pricing.mjs"); + assert.equal(typeof mod.registerPricing, "function"); + assert.equal(typeof mod.runPricingSync, "function"); + assert.equal(typeof mod.runPricingList, "function"); +});