Skip to content

Commit 2495afd

Browse files
feat: manual llm options (#614)
1 parent b326144 commit 2495afd

File tree

11 files changed

+407
-58
lines changed

11 files changed

+407
-58
lines changed

.changeset/fair-books-admire.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@lingo.dev/_spec": minor
3+
"lingo.dev": minor
4+
---
5+
6+
add basic translators

packages/cli/i18n.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
{
2-
"version": 1.2,
2+
"version": 1.5,
3+
"provider": {
4+
"id": "anthropic",
5+
"model": "claude-3-7-sonnet-latest",
6+
"prompt": "You're translating text from {source} to {target}."
7+
},
38
"locale": {
49
"source": "en",
510
"targets": ["es", "fr", "it", "de", "ar"]

packages/cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,16 @@
5757
"author": "",
5858
"license": "Apache-2.0",
5959
"dependencies": {
60+
"@ai-sdk/anthropic": "^1.2.6",
61+
"@ai-sdk/openai": "^1.3.7",
6062
"@datocms/cma-client-node": "^3.4.0",
6163
"@gitbeaker/rest": "^39.34.3",
6264
"@inquirer/prompts": "^7.2.3",
6365
"@lingo.dev/_sdk": "workspace:*",
6466
"@lingo.dev/_spec": "workspace:*",
6567
"@modelcontextprotocol/sdk": "^1.5.0",
6668
"@paralleldrive/cuid2": "^2.2.2",
69+
"ai": "^4.3.2",
6770
"bitbucket": "^2.12.0",
6871
"chalk": "^5.4.1",
6972
"cors": "^2.8.5",

packages/cli/src/cli/cmd/i18n.ts

Lines changed: 6 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { bucketTypeSchema, I18nConfig, localeCodeSchema, resolveOverriddenLocale } from "@lingo.dev/_spec";
2-
import { LingoDotDevEngine } from "@lingo.dev/_sdk";
32
import { Command } from "interactive-commander";
43
import Z from "zod";
54
import _ from "lodash";
@@ -17,6 +16,8 @@ import inquirer from "inquirer";
1716
import externalEditor from "external-editor";
1817
import { cacheChunk, deleteCache, getNormalizedCache } from "../utils/cache";
1918
import updateGitignore from "../utils/update-gitignore";
19+
import createProcessor from "../processor";
20+
import { withExponentialBackoff } from "../utils/exp-backoff";
2021

2122
export default new Command()
2223
.command("i18n")
@@ -299,11 +300,13 @@ export default new Command()
299300
bucketOra.start(
300301
`[${sourceLocale} -> ${targetLocale}] [${Object.keys(processableData).length} entries] (0%) AI localization in progress...`,
301302
);
302-
const localizationEngine = createLocalizationEngineConnection({
303+
let processPayload = createProcessor(i18nConfig!.provider, {
303304
apiKey: settings.auth.apiKey,
304305
apiUrl: settings.auth.apiUrl,
305306
});
306-
const processedTargetData = await localizationEngine.process(
307+
processPayload = withExponentialBackoff(processPayload, 3, 1000);
308+
309+
const processedTargetData = await processPayload(
307310
{
308311
sourceLocale,
309312
sourceData,
@@ -435,47 +438,6 @@ async function retryWithExponentialBackoff<T>(
435438
throw new Error("Unreachable code");
436439
}
437440

438-
function createLocalizationEngineConnection(params: { apiKey: string; apiUrl: string; maxRetries?: number }) {
439-
const engine = new LingoDotDevEngine({
440-
apiKey: params.apiKey,
441-
apiUrl: params.apiUrl,
442-
});
443-
444-
return {
445-
process: async (
446-
args: {
447-
sourceLocale: string;
448-
sourceData: Record<string, any>;
449-
processableData: Record<string, any>;
450-
targetLocale: string;
451-
targetData: Record<string, any>;
452-
},
453-
onProgress: (
454-
progress: number,
455-
sourceChunk: Record<string, string>,
456-
processedChunk: Record<string, string>,
457-
) => void,
458-
) => {
459-
return retryWithExponentialBackoff(
460-
() =>
461-
engine.localizeObject(
462-
args.processableData,
463-
{
464-
sourceLocale: args.sourceLocale,
465-
targetLocale: args.targetLocale,
466-
reference: {
467-
[args.sourceLocale]: args.sourceData,
468-
[args.targetLocale]: args.targetData,
469-
},
470-
},
471-
onProgress,
472-
),
473-
params.maxRetries ?? 3,
474-
);
475-
},
476-
};
477-
}
478-
479441
function parseFlags(options: any) {
480442
return Z.object({
481443
apiKey: Z.string().optional(),
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type LocalizerInput = {
2+
sourceLocale: string;
3+
sourceData: Record<string, any>;
4+
processableData: Record<string, any>;
5+
targetLocale: string;
6+
targetData: Record<string, any>;
7+
};
8+
9+
export type LocalizerProgressFn = (
10+
progress: number,
11+
sourceChunk: Record<string, string>,
12+
processedChunk: Record<string, string>,
13+
) => void;
14+
15+
export type LocalizerFn = (input: LocalizerInput, onProgress: LocalizerProgressFn) => Promise<Record<string, any>>;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { I18nConfig } from "@lingo.dev/_spec";
2+
import { LocalizerFn } from "./_base";
3+
import { createLingoLocalizer } from "./lingo";
4+
import { createBasicTranslator } from "./openai";
5+
import { createOpenAI } from "@ai-sdk/openai";
6+
import { createAnthropic } from "@ai-sdk/anthropic";
7+
8+
export default function createProcessor(
9+
provider: I18nConfig["provider"],
10+
params: { apiKey: string; apiUrl: string },
11+
): LocalizerFn {
12+
if (!provider || provider.id === "lingo") {
13+
const result = createLingoLocalizer(params);
14+
return result;
15+
} else {
16+
const model = getPureModelProvider(provider);
17+
const result = createBasicTranslator(model, provider.prompt);
18+
return result;
19+
}
20+
}
21+
22+
function getPureModelProvider(provider: I18nConfig["provider"]) {
23+
switch (provider?.id) {
24+
case "openai":
25+
if (!process.env.OPENAI_API_KEY) {
26+
throw new Error("OPENAI_API_KEY is not set.");
27+
}
28+
return createOpenAI({
29+
apiKey: process.env.OPENAI_API_KEY,
30+
baseURL: provider.baseUrl,
31+
})(provider.model);
32+
case "anthropic":
33+
if (!process.env.ANTHROPIC_API_KEY) {
34+
throw new Error("ANTHROPIC_API_KEY is not set.");
35+
}
36+
return createAnthropic({
37+
apiKey: process.env.ANTHROPIC_API_KEY,
38+
})(provider.model);
39+
default:
40+
throw new Error(`Unsupported provider: ${provider?.id}`);
41+
}
42+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { LingoDotDevEngine } from "@lingo.dev/_sdk";
2+
import { LocalizerInput, LocalizerProgressFn } from "./_base";
3+
4+
export function createLingoLocalizer(params: { apiKey: string; apiUrl: string }) {
5+
return async (input: LocalizerInput, onProgress: LocalizerProgressFn) => {
6+
const lingo = new LingoDotDevEngine({
7+
apiKey: params.apiKey,
8+
apiUrl: params.apiUrl,
9+
});
10+
11+
const result = await lingo.localizeObject(
12+
input.processableData,
13+
{
14+
sourceLocale: input.sourceLocale,
15+
targetLocale: input.targetLocale,
16+
reference: {
17+
[input.sourceLocale]: input.sourceData,
18+
[input.targetLocale]: input.targetData,
19+
},
20+
},
21+
onProgress,
22+
);
23+
24+
return result;
25+
};
26+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { generateText, LanguageModelV1 } from "ai";
2+
import { LocalizerInput, LocalizerProgressFn } from "./_base";
3+
4+
export function createBasicTranslator(model: LanguageModelV1, systemPrompt: string) {
5+
return async (input: LocalizerInput, onProgress: LocalizerProgressFn) => {
6+
if (!process.env.OPENAI_API_KEY) {
7+
throw new Error("OPENAI_API_KEY is not set");
8+
}
9+
10+
const response = await generateText({
11+
model,
12+
messages: [
13+
{
14+
role: "system",
15+
content: JSON.stringify({
16+
role: "system",
17+
content: systemPrompt.replaceAll("{source}", input.sourceLocale).replaceAll("{target}", input.targetLocale),
18+
}),
19+
},
20+
{
21+
role: "user",
22+
content: JSON.stringify({
23+
sourceLocale: "en",
24+
targetLocale: "es",
25+
data: {
26+
message: "Hello, world!",
27+
},
28+
}),
29+
},
30+
{
31+
role: "assistant",
32+
content: JSON.stringify({
33+
sourceLocale: "en",
34+
targetLocale: "es",
35+
data: {
36+
message: "Hola, mundo!",
37+
},
38+
}),
39+
},
40+
{
41+
role: "user",
42+
content: JSON.stringify({
43+
sourceLocale: "en",
44+
targetLocale: "es",
45+
data: input.processableData,
46+
}),
47+
},
48+
],
49+
});
50+
51+
const result = JSON.parse(response.text);
52+
53+
return result;
54+
};
55+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export function withExponentialBackoff<T, Args extends any[]>(
2+
fn: (...args: Args) => Promise<T>,
3+
maxAttempts: number = 3,
4+
baseDelay: number = 1000,
5+
): (...args: Args) => Promise<T> {
6+
return async (...args: Args): Promise<T> => {
7+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
8+
try {
9+
return await fn(...args);
10+
} catch (error) {
11+
if (attempt === maxAttempts - 1) throw error;
12+
13+
const delay = baseDelay * Math.pow(2, attempt);
14+
await new Promise((resolve) => setTimeout(resolve, delay));
15+
}
16+
}
17+
throw new Error("Unreachable code");
18+
};
19+
}

packages/spec/src/config.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ type ConfigDefinitionExtensionParams<T extends Z.ZodRawShape, P extends Z.ZodRaw
2323
createUpgrader: (
2424
config: Z.infer<Z.ZodObject<P>>,
2525
schema: Z.ZodObject<T>,
26-
defaultValue: Z.infer<Z.ZodObject<T>>
26+
defaultValue: Z.infer<Z.ZodObject<T>>,
2727
) => Z.infer<Z.ZodObject<T>>;
2828
};
2929
const extendConfigDefinition = <T extends Z.ZodRawShape, P extends Z.ZodRawShape>(
3030
definition: ConfigDefinition<P, any>,
31-
params: ConfigDefinitionExtensionParams<T, P>
31+
params: ConfigDefinitionExtensionParams<T, P>,
3232
) => {
3333
const schema = params.createSchema(definition.schema);
3434
const defaultValue = params.createDefaultValue(definition.defaultValue);
@@ -120,7 +120,7 @@ export const configV1_1Definition = extendConfigDefinition(configV1Definition, {
120120
Z.object({
121121
include: Z.array(Z.string()).default([]),
122122
exclude: Z.array(Z.string()).default([]).optional(),
123-
})
123+
}),
124124
).default({}),
125125
}),
126126
createDefaultValue: (baseDefaultValue) => ({
@@ -188,7 +188,7 @@ export const configV1_3Definition = extendConfigDefinition(configV1_2Definition,
188188
.default([])
189189
.optional(),
190190
injectLocale: Z.array(Z.string()).optional(),
191-
})
191+
}),
192192
).default({}),
193193
}),
194194
createDefaultValue: (baseDefaultValue) => ({
@@ -222,8 +222,47 @@ export const configV1_4Definition = extendConfigDefinition(configV1_3Definition,
222222
}),
223223
});
224224

225+
// v1.4 -> v1.5
226+
// Changes: add "provider" field to the config
227+
const commonProviderSchema = Z.object({
228+
id: Z.string(),
229+
model: Z.string(),
230+
prompt: Z.string(),
231+
baseUrl: Z.string().optional(),
232+
});
233+
const providerSchema = Z.union([
234+
commonProviderSchema.extend({
235+
id: Z.literal("lingo"),
236+
model: Z.literal("best"),
237+
}),
238+
commonProviderSchema.extend({
239+
id: Z.enum(["openai", "anthropic"]),
240+
}),
241+
]);
242+
export const configV1_5Definition = extendConfigDefinition(configV1_4Definition, {
243+
createSchema: (baseSchema) =>
244+
baseSchema.extend({
245+
provider: providerSchema
246+
.default({
247+
id: "lingo",
248+
model: "best",
249+
baseUrl: "https://engine.lingo.dev",
250+
prompt: "",
251+
})
252+
.optional(),
253+
}),
254+
createDefaultValue: (baseDefaultValue) => ({
255+
...baseDefaultValue,
256+
version: 1.5,
257+
}),
258+
createUpgrader: (oldConfig) => ({
259+
...oldConfig,
260+
version: 1.5,
261+
}),
262+
});
263+
225264
// exports
226-
export const LATEST_CONFIG_DEFINITION = configV1_4Definition;
265+
export const LATEST_CONFIG_DEFINITION = configV1_5Definition;
227266

228267
export type I18nConfig = Z.infer<(typeof LATEST_CONFIG_DEFINITION)["schema"]>;
229268

0 commit comments

Comments
 (0)