Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/run-estimate-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"lingo.dev": minor
"@lingo.dev/_sdk": minor
---

Add `lingo.dev run --estimate`: print the approximate cost of pending translations and exit without translating. The CLI computes the same change delta as a regular run, sends per-locale character counts to the new `/process/estimate` endpoint, and prints a per-locale cost breakdown. The SDK gains a matching `estimate()` method.
1 change: 1 addition & 0 deletions packages/cli/src/cli/cmd/run/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ export const flagsSchema = z.object({
debounce: z.number().positive().prefault(5000), // 5 seconds default
sound: z.boolean().optional(),
pseudo: z.boolean().optional(),
estimate: z.boolean().prefault(false),
});
export type CmdRunFlags = z.infer<typeof flagsSchema>;
58 changes: 57 additions & 1 deletion packages/cli/src/cli/cmd/run/_utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,61 @@
import { CmdRunContext } from "./_types";
import _ from "lodash";
import { minimatch } from "minimatch";

import { CmdRunContext, CmdRunTask } from "./_types";
import { UserIdentity } from "../../utils/observability";
import { safeDecode } from "../../utils/key-matching";
import createBucketLoader from "../../loaders";
import { Delta } from "../../utils/delta";

export function createLoaderForTask(assignedTask: CmdRunTask) {
const bucketLoader = createBucketLoader(
assignedTask.bucketType,
assignedTask.bucketPathPattern,
{
defaultLocale: assignedTask.sourceLocale,
injectLocale: assignedTask.injectLocale,
formatter: assignedTask.formatter,
keyColumn: assignedTask.keyColumn,
},
assignedTask.lockedKeys,
assignedTask.lockedPatterns,
assignedTask.ignoredKeys,
assignedTask.preservedKeys,
assignedTask.localizableKeys,
);
bucketLoader.setDefaultLocale(assignedTask.sourceLocale);

return bucketLoader;
}

/**
* The subset of source entries that actually needs translation for a task:
* delta-changed keys (or everything with --force), narrowed by --key filters.
* Shared by execute (what gets sent to the localizer) and estimate (what
* gets counted) so the two can never disagree on scope.
*/
export function computeProcessableData(
sourceData: Record<string, any>,
delta: Delta,
force: boolean | undefined,
onlyKeys: string[],
): Record<string, any> {
return _.chain(sourceData)
.entries()
.filter(
([key]) =>
delta.added.includes(key) || delta.updated.includes(key) || !!force,
)
.filter(
([key]) =>
!onlyKeys.length ||
onlyKeys.some((pattern) =>
minimatch(safeDecode(key), safeDecode(pattern)),
),
)
.fromPairs()
.value();
}

/**
* Determines the user's identity for tracking purposes.
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/cli/cmd/run/estimate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { countTranslatableChars } from "./estimate";
import { computeProcessableData } from "./_utils";
import { Delta } from "../../utils/delta";

const delta = (added: string[] = [], updated: string[] = []): Delta => ({
added,
removed: [],
updated,
renamed: [],
hasChanges: !!added.length || !!updated.length,
});

describe("countTranslatableChars", () => {
it("sums the lengths of string leaf values only", () => {
expect(
countTranslatableChars({
greeting: "Hello", // 5
farewell: "Bye", // 3
count: 42,
flag: true,
}),
).toBe(8);
});

it("returns 0 for empty data", () => {
expect(countTranslatableChars({})).toBe(0);
});
});

describe("computeProcessableData", () => {
const sourceData = {
"a.title": "Title",
"a.body": "Body",
"b.title": "Other",
};

it("keeps only delta-changed keys", () => {
const result = computeProcessableData(
sourceData,
delta(["a.title"], ["b.title"]),
false,
[],
);
expect(Object.keys(result)).toEqual(["a.title", "b.title"]);
});

it("keeps everything with force", () => {
const result = computeProcessableData(sourceData, delta(), true, []);
expect(Object.keys(result)).toEqual(Object.keys(sourceData));
});

it("narrows by key patterns", () => {
const result = computeProcessableData(sourceData, delta(), true, ["a.*"]);
expect(Object.keys(result)).toEqual(["a.title", "a.body"]);
});

it("returns empty when nothing changed", () => {
expect(computeProcessableData(sourceData, delta(), false, [])).toEqual({});
});
});
125 changes: 125 additions & 0 deletions packages/cli/src/cli/cmd/run/estimate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import chalk from "chalk";
import { Listr } from "listr2";

import { colors } from "../../constants";
import { CmdRunContext } from "./_types";
import { commonTaskRendererOptions } from "./_const";
import { createDeltaProcessor } from "../../utils/delta";
import { computeProcessableData, createLoaderForTask } from "./_utils";

/**
* Translatable characters of a task's processable data: the sum of leaf
* string-value lengths — keys, markup and serialization syntax excluded.
* Matches how the server-side estimate counts characters.
*/
export function countTranslatableChars(
processableData: Record<string, any>,
): number {
return Object.values(processableData).reduce(
(sum, value) => (typeof value === "string" ? sum + value.length : sum),
0,
);
}

const formatUsd = (value: number) =>
value < 0.01 && value > 0 ? "<$0.01" : `$${value.toFixed(2)}`;

/**
* `run --estimate`: compute the same per-task translation delta as execute,
* but instead of translating, send per-locale character counts to
* `/process/estimate` and print the approximate cost. Nothing is translated,
* written, or billed; lockfile and target files stay untouched.
*/
export default async function estimate(
input: CmdRunContext,
): Promise<CmdRunContext> {
console.log(chalk.hex(colors.orange)("[Estimate]"));

if (!input.localizer?.estimate) {
throw new Error(
`Cost estimate is not available for the "${input.localizer?.id}" provider. ` +
`Estimates use Lingo.dev server-side pricing — remove --estimate or switch to the Lingo.dev provider.`,
);
}

const charsByLocale = new Map<string, number>();

return new Listr<CmdRunContext>(
[
{
title: "Computing translation delta",
task: async (ctx, task) => {
if (!ctx.tasks.length) {
task.title = "Nothing to estimate — everything is up to date.";
return;
}

for (const runTask of ctx.tasks) {
const bucketLoader = createLoaderForTask(runTask);
const deltaProcessor = createDeltaProcessor(
runTask.bucketPathPattern,
);
const checksums = await deltaProcessor.loadChecksums();
const sourceData = await bucketLoader.pull(runTask.sourceLocale);
const targetData = await bucketLoader.pull(runTask.targetLocale);
const delta = await deltaProcessor.calculateDelta({
sourceData,
targetData,
checksums,
});
const processableData = computeProcessableData(
sourceData,
delta,
ctx.flags.force,
runTask.onlyKeys,
);

const chars = countTranslatableChars(processableData);
charsByLocale.set(
runTask.targetLocale,
(charsByLocale.get(runTask.targetLocale) ?? 0) + chars,
);
}

task.title = `Delta computed for ${chalk.hex(colors.yellow)(
ctx.tasks.length.toString(),
)} task(s)`;
},
},
{
title: "Fetching cost estimate",
rendererOptions: { persistentOutput: true },
task: async (ctx, task) => {
const items = [...charsByLocale.entries()].map(
([targetLocale, sourceChars]) => ({ targetLocale, sourceChars }),
);

if (!items.length || items.every((item) => !item.sourceChars)) {
task.title = "Estimated cost: $0.00 — nothing needs translation.";
return;
}

const result = await ctx.localizer!.estimate!(items);

const lines = result.byLocale.map(
(row) =>
` ${chalk.hex(colors.yellow)(row.targetLocale)}: ~${formatUsd(
row.estimatedCostUsd,
)} ${chalk.dim(
`(${row.sourceChars.toLocaleString("en-US")} chars, ~${row.estimatedOutputTokens.toLocaleString("en-US")} tokens)`,
)}`,
);

task.title = `Estimated cost: ~${chalk.hex(colors.green)(
formatUsd(result.totals.estimatedTotalCostUsd),
)} ${chalk.dim("(estimate, not a quote — nothing was translated)")}`;
task.output = lines.join("\n");
},
},
],
{
exitOnError: true,
rendererOptions: commonTaskRendererOptions,
},
).run(input);
}
48 changes: 7 additions & 41 deletions packages/cli/src/cli/cmd/run/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ import chalk from "chalk";
import { Listr, ListrTask } from "listr2";
import pLimit, { LimitFunction } from "p-limit";
import _ from "lodash";
import { minimatch } from "minimatch";

import { safeDecode } from "../../utils/key-matching";
import { colors } from "../../constants";
import { CmdRunContext, CmdRunTask, CmdRunTaskResult } from "./_types";
import { commonTaskRendererOptions } from "./_const";
import createBucketLoader from "../../loaders";
import { createDeltaProcessor, Delta } from "../../utils/delta";
import { computeProcessableData, createLoaderForTask } from "./_utils";

const WARN_CONCURRENCY_COUNT = 30;

Expand Down Expand Up @@ -148,27 +146,6 @@ function createExecutionProgressMessage(ctx: CmdRunContext) {
}, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim(skippedTasksCount)}`;
}

function createLoaderForTask(assignedTask: CmdRunTask) {
const bucketLoader = createBucketLoader(
assignedTask.bucketType,
assignedTask.bucketPathPattern,
{
defaultLocale: assignedTask.sourceLocale,
injectLocale: assignedTask.injectLocale,
formatter: assignedTask.formatter,
keyColumn: assignedTask.keyColumn,
},
assignedTask.lockedKeys,
assignedTask.lockedPatterns,
assignedTask.ignoredKeys,
assignedTask.preservedKeys,
assignedTask.localizableKeys,
);
bucketLoader.setDefaultLocale(assignedTask.sourceLocale);

return bucketLoader;
}

function createWorkerTask(args: {
ctx: CmdRunContext;
assignedTasks: CmdRunTask[];
Expand Down Expand Up @@ -217,23 +194,12 @@ function createWorkerTask(args: {
checksums: initialChecksums,
});

const processableData = _.chain(sourceData)
.entries()
.filter(
([key, value]) =>
delta.added.includes(key) ||
delta.updated.includes(key) ||
!!args.ctx.flags.force,
)
.filter(
([key]) =>
!assignedTask.onlyKeys.length ||
assignedTask.onlyKeys?.some((pattern) =>
minimatch(safeDecode(key), safeDecode(pattern)),
),
)
.fromPairs()
.value();
const processableData = computeProcessableData(
sourceData,
delta,
args.ctx.flags.force,
assignedTask.onlyKeys,
);

if (!Object.keys(processableData).length) {
await fileIoLimiter(async () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/cli/cmd/run/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import os from "os";
import setup from "./setup";
import plan from "./plan";
import execute from "./execute";
import estimate from "./estimate";
import watch from "./watch";
import { CmdRunContext, flagsSchema } from "./_types";
import frozen from "./frozen";
Expand Down Expand Up @@ -123,6 +124,10 @@ export default new Command()
"--pseudo",
"Enable pseudo-localization mode: automatically pseudo-translates all extracted strings with accented characters and visual markers without calling any external API. Useful for testing UI internationalization readiness",
)
.option(
"--estimate",
"Print the estimated cost of pending translations and exit without translating. Computes the same change delta as a regular run and prices it via the Lingo.dev API; values are estimates, not quotes",
)
.action(async (args) => {
let userIdentity: UserIdentity = null;
try {
Expand All @@ -134,6 +139,12 @@ export default new Command()
localizer: null,
};

if (ctx.flags.estimate && (ctx.flags.watch || ctx.flags.frozen)) {
throw new Error(
"--estimate cannot be combined with --watch or --frozen. Run it on its own to preview the cost of the next run.",
);
}

await pauseIfDebug(ctx.flags.debug);
await renderClear();
await renderSpacer();
Expand All @@ -155,6 +166,12 @@ export default new Command()
await plan(ctx);
await renderSpacer();

if (ctx.flags.estimate) {
await estimate(ctx);
await renderSpacer();
return;
}

await frozen(ctx);
await renderSpacer();

Expand Down
Loading
Loading