UNPKG

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
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; }