i18n-ai-translate
Version:
AI-powered localization CLI, Node library, and GitHub Action. Translate i18next JSON, Gettext PO, Java .properties, and iOS .strings with ChatGPT, Claude, Gemini, or local Ollama models.
276 lines (236 loc) • 9.37 kB
text/typescript
import { DEFAULT_CONCURRENCY, DEFAULT_MODEL } from "./constants";
import { OVERRIDE_PROMPT_KEYS } from "./interfaces/override_prompt";
import { printWarn } from "./utils";
import Engine from "./enums/engine";
import PromptMode from "./enums/prompt_mode";
import fs from "fs";
import path from "path";
import type { ChatParams, Model, ModelArgs } from "./types";
import type OverridePrompt from "./interfaces/override_prompt";
/**
* Processes the command line arguments to extract model parameters.
* @param options - The command line options object.
* @returns an object containing the model parameters.
*
*/
export function processModelArgs(options: any): ModelArgs {
let model: Model;
let chatParams: ChatParams;
let rateLimitMs = Number(options.rateLimitMs);
let apiKey: string | undefined;
let host: string | undefined;
let promptMode = options.promptMode as PromptMode;
let batchSize = Number(options.batchSize);
let batchMaxTokens = Number(options.batchMaxTokens);
let concurrency = Number(options.concurrency);
if (!options.concurrency || !Number.isFinite(concurrency)) {
concurrency = DEFAULT_CONCURRENCY;
}
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new Error("--concurrency must be a positive integer");
}
// User-supplied --tokens-per-minute overrides the engine default.
// 0 explicitly disables the cap; undefined defers to the per-engine
// default assigned in the switch below.
let tokensPerMinute: number | undefined;
if (options.tokensPerMinute !== undefined) {
const parsed = Number(options.tokensPerMinute);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(
"--tokens-per-minute must be a non-negative number",
);
}
tokensPerMinute = parsed === 0 ? undefined : parsed;
}
switch (options.engine) {
case Engine.Gemini:
model = options.model || DEFAULT_MODEL[Engine.Gemini];
chatParams = {};
if (!options.rateLimitMs) {
// gemini-2.0-flash-exp limits us to 10 RPM => 1 call every 6 seconds
rateLimitMs = 6000;
}
if (!process.env.GEMINI_API_KEY && !options.apiKey) {
throw new Error("GEMINI_API_KEY not found in .env file");
} else {
apiKey = options.apiKey || process.env.GEMINI_API_KEY;
}
if (!options.promptMode) {
promptMode = PromptMode.JSON;
} else if (promptMode === PromptMode.CSV) {
printWarn("JSON mode recommended for Gemini");
}
if (!options.batchSize) {
batchSize = 32;
}
if (!options.batchMaxTokens) {
batchMaxTokens = 4096;
}
// No default TPM cap for Gemini — the existing --rate-limit-ms
// default (6s, matching ~10 RPM free tier) already prevents
// bursts. Paid-tier users can opt in via --tokens-per-minute.
// Published Gemini 2.5 Flash paid tier is ~250k TPM; use that
// as a user-facing hint in docs rather than a default.
break;
case Engine.ChatGPT:
model = options.model || DEFAULT_MODEL[Engine.ChatGPT];
chatParams = {
messages: [],
model,
seed: 69420,
};
if (!options.rateLimitMs) {
// Free-tier rate limits are 3 RPM => 1 call every 20 seconds
// Tier 1 is a reasonable 500 RPM => 1 call every 120ms.
rateLimitMs = 120;
}
if (!process.env.OPENAI_API_KEY && !options.apiKey) {
throw new Error("OPENAI_API_KEY not found in .env file");
} else {
apiKey = options.apiKey || process.env.OPENAI_API_KEY;
}
if (!options.promptMode) {
promptMode = PromptMode.CSV;
}
if (!options.batchSize) {
batchSize = 32;
}
if (!options.batchMaxTokens) {
batchMaxTokens = 4096;
}
// No default TPM cap. The free tier publishes no TPM and
// Tier-1 is ~200k TPM for GPT-5.x — a silent default risks
// mysteriously throttling paid-tier users whose limits are
// higher, while also risking undercounting on the free tier
// where only the RPM gate matters. Users opt in via
// --tokens-per-minute once they know their tier.
break;
case Engine.Ollama:
model = options.model || DEFAULT_MODEL[Engine.Ollama];
chatParams = {
messages: [],
model,
seed: 69420,
};
host = options.host || process.env.OLLAMA_HOSTNAME;
if (!options.promptMode) {
promptMode = PromptMode.JSON;
} else if (promptMode === PromptMode.CSV) {
printWarn("JSON mode recommended for Ollama");
}
if (!options.batchSize) {
// Ollama's error rate is high with large batches
batchSize = 16;
}
if (!options.batchMaxTokens) {
// Ollama's default amount of tokens per request
batchMaxTokens = 2048;
}
break;
case Engine.Claude:
model = options.model || DEFAULT_MODEL[Engine.Claude];
chatParams = {
messages: [],
model,
};
if (!options.rateLimitMs) {
// Anthropic limits us to 50 RPM on the first tier => 1200ms between calls
rateLimitMs = 1200;
}
if (!process.env.ANTHROPIC_API_KEY && !options.apiKey) {
throw new Error("ANTHROPIC_API_KEY not found in .env file");
} else {
apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY;
}
if (!options.promptMode) {
promptMode = PromptMode.CSV;
}
if (!options.batchSize) {
batchSize = 32;
}
// No default TPM cap. Claude's free tier is 5 RPM / 20k TPM;
// Tier-1 is 50 RPM / 40k TPM; higher tiers go up from there.
// The RPM gate (1200ms default) paces calls enough that most
// users won't blow TPM without concurrency > 1. Opt in via
// --tokens-per-minute 20000 on free tier, 40000 on Tier-1.
break;
default: {
throw new Error("Invalid engine");
}
}
switch (promptMode) {
case PromptMode.CSV:
if (options.batchMaxTokens) {
throw new Error("'--batch-max-tokens' is not used in CSV mode");
}
break;
case PromptMode.JSON:
if (options.skipStylingVerification) {
throw new Error(
"'--skip-styling-verification' is not used in JSON mode",
);
}
if (options.engine === Engine.Claude) {
throw new Error("JSON mode is not compatible with Anthropic");
}
break;
default: {
throw new Error("Invalid prompt mode");
}
}
return {
apiKey,
batchMaxTokens,
batchSize,
chatParams,
concurrency,
host,
model: options.model || DEFAULT_MODEL[options.engine as Engine],
promptMode,
rateLimitMs,
tokensPerMinute,
};
}
/**
* Processes the override prompt file.
* @param overridePromptFilePath - The path to the override prompt file.
* @returns an object containing the override prompt.
*/
export function processOverridePromptFile(
overridePromptFilePath: string,
): OverridePrompt {
const filePath = path.resolve(process.cwd(), overridePromptFilePath);
if (!fs.existsSync(filePath)) {
throw new Error(
`The override prompt file does not exist at ${filePath}`,
);
}
let overridePrompt: OverridePrompt;
try {
overridePrompt = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch (err) {
throw new Error(
`Failed to read the override prompt file. err = ${err}`,
);
}
if (Object.keys(overridePrompt).length === 0) {
throw new Error(
`Received an empty object for the override prompt file. Valid keys are: ${OVERRIDE_PROMPT_KEYS.join(", ")}`,
);
}
for (const key of Object.keys(overridePrompt) as (keyof OverridePrompt)[]) {
if (!OVERRIDE_PROMPT_KEYS.includes(key)) {
throw new Error(
`Received an unexpected key ${key} in the override prompt file. Valid keys are: ${OVERRIDE_PROMPT_KEYS.join(", ")}`,
);
}
}
for (const value of Object.values(overridePrompt)) {
if (typeof value !== "string") {
throw new Error(
`Expected a string as a key for every entry in the override prompt file. Received: ${typeof value}`,
);
}
}
return overridePrompt;
}