Skip to content
Draft
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
14 changes: 6 additions & 8 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,9 @@
"author": "",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "^1.2.11",
"@ai-sdk/google": "^1.2.19",
"@ai-sdk/mistral": "^1.2.8",
"@ai-sdk/openai": "^1.3.22",
"@lingo.dev/config": "workspace:*",
"@lingo.dev/providers": "workspace:*",

"@babel/generator": "^7.27.1",
"@babel/parser": "^7.27.1",
"@babel/traverse": "^7.27.4",
Expand All @@ -143,7 +142,7 @@
"@lingo.dev/_spec": "workspace:*",
"@markdoc/markdoc": "^0.5.4",
"@modelcontextprotocol/sdk": "^1.5.0",
"@openrouter/ai-sdk-provider": "^0.7.1",

"@paralleldrive/cuid2": "^2.2.2",
"@types/ejs": "^3.1.5",
"ai": "^4.3.15",
Expand All @@ -168,7 +167,6 @@
"glob": "<11.0.0",
"gradient-string": "^3.0.0",
"gray-matter": "^4.0.3",
"ini": "^5.0.0",
"ink": "^4.2.0",
"ink-progress-bar": "^3.0.0",
"ink-spinner": "^5.0.0",
Expand All @@ -189,7 +187,7 @@
"node-webvtt": "^1.9.4",
"object-hash": "^3.0.0",
"octokit": "^4.0.2",
"ollama-ai-provider": "^1.2.0",

"open": "^10.2.0",
"ora": "^8.1.1",
"p-limit": "^6.2.0",
Expand Down Expand Up @@ -227,7 +225,7 @@
"@types/figlet": "^1.7.0",
"@types/gettext-parser": "^4.0.4",
"@types/glob": "^8.1.0",
"@types/ini": "^4.1.1",

"@types/is-url": "^1.2.32",
"@types/jsdom": "^21.1.7",
"@types/lodash": "^4.17.16",
Expand Down
166 changes: 53 additions & 113 deletions packages/cli/src/cli/localizer/explicit.ts
Original file line number Diff line number Diff line change
@@ -1,144 +1,84 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { createMistral } from "@ai-sdk/mistral";
import { I18nConfig } from "@lingo.dev/_spec";
import chalk from "chalk";
import dedent from "dedent";
import { ILocalizer, LocalizerData } from "./_types";
import { LanguageModel, Message, generateText } from "ai";
import { colors } from "../constants";
import { jsonrepair } from "jsonrepair";
import { createOllama } from "ollama-ai-provider";
import {
createProviderClient,
ProviderKeyMissingError,
PROVIDER_METADATA,
SUPPORTED_PROVIDERS,
type ProviderId,
} from "@lingo.dev/providers";

export default function createExplicitLocalizer(
provider: NonNullable<I18nConfig["provider"]>,
): ILocalizer {
const settings = provider.settings || {};
const supported = new Set(SUPPORTED_PROVIDERS as readonly string[]);

switch (provider.id) {
default:
if (!supported.has(provider.id as any)) {
throw new Error(
dedent`
You're trying to use unsupported provider: ${chalk.dim(provider.id)}.

To fix this issue:
1. Switch to one of the supported providers, or
2. Remove the ${chalk.italic("provider")} node from your i18n.json configuration to switch to ${chalk.hex(
colors.green,
)("Lingo.dev")}

${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")}
`,
);
}

try {
const model = createProviderClient(provider.id as ProviderId, provider.model, {
baseUrl: provider.baseUrl,
});
return createLocalizerFromModel({
model,
id: provider.id,
prompt: provider.prompt,
});
} catch (error: unknown) {
if (error instanceof ProviderKeyMissingError) {
const meta = PROVIDER_METADATA[error.providerId];
const envVar = meta?.apiKeyEnvVar;
throw new Error(
dedent`
You're trying to use unsupported provider: ${chalk.dim(provider.id)}.
You're trying to use raw ${chalk.dim(provider.id)} API for translation. ${
envVar
? `However, ${chalk.dim(envVar)} environment variable is not set.`
: "However, that provider is unavailable."
}

To fix this issue:
1. Switch to one of the supported providers, or
2. Remove the ${chalk.italic(
"provider",
)} node from your i18n.json configuration to switch to ${chalk.hex(
1. ${
envVar
? `Set ${chalk.dim(envVar)} in your environment variables`
: "Set the environment variable for your provider (if required)"
}, or
2. Remove the ${chalk.italic("provider")} node from your i18n.json configuration to switch to ${chalk.hex(
colors.green,
)("Lingo.dev")}

${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")}
`,
);
case "openai":
return createAiSdkLocalizer({
factory: (params) => createOpenAI(params).languageModel(provider.model),
id: provider.id,
prompt: provider.prompt,
apiKeyName: "OPENAI_API_KEY",
baseUrl: provider.baseUrl,
settings,
});
case "anthropic":
return createAiSdkLocalizer({
factory: (params) =>
createAnthropic(params).languageModel(provider.model),
id: provider.id,
prompt: provider.prompt,
apiKeyName: "ANTHROPIC_API_KEY",
baseUrl: provider.baseUrl,
settings,
});
case "google":
return createAiSdkLocalizer({
factory: (params) =>
createGoogleGenerativeAI(params).languageModel(provider.model),
id: provider.id,
prompt: provider.prompt,
apiKeyName: "GOOGLE_API_KEY",
baseUrl: provider.baseUrl,
settings,
});
case "openrouter":
return createAiSdkLocalizer({
factory: (params) =>
createOpenRouter(params).languageModel(provider.model),
id: provider.id,
prompt: provider.prompt,
apiKeyName: "OPENROUTER_API_KEY",
baseUrl: provider.baseUrl,
settings,
});
case "ollama":
return createAiSdkLocalizer({
factory: (_params) => createOllama().languageModel(provider.model),
id: provider.id,
prompt: provider.prompt,
skipAuth: true,
settings,
});
case "mistral":
return createAiSdkLocalizer({
factory: (params) =>
createMistral(params).languageModel(provider.model),
id: provider.id,
prompt: provider.prompt,
apiKeyName: "MISTRAL_API_KEY",
baseUrl: provider.baseUrl,
settings,
});
}
throw error as Error;
}
}

function createAiSdkLocalizer(params: {
factory: (params: { apiKey?: string; baseUrl?: string }) => LanguageModel;
function createLocalizerFromModel(params: {
model: LanguageModel;
id: NonNullable<I18nConfig["provider"]>["id"];
prompt: string;
apiKeyName?: string;
baseUrl?: string;
skipAuth?: boolean;
settings?: { temperature?: number };
}): ILocalizer {
const skipAuth = params.skipAuth === true;

const apiKey = process.env[params?.apiKeyName ?? ""];
if ((!skipAuth && !apiKey) || !params.apiKeyName) {
throw new Error(
dedent`
You're trying to use raw ${chalk.dim(params.id)} API for translation. ${
params.apiKeyName
? `However, ${chalk.dim(
params.apiKeyName,
)} environment variable is not set.`
: "However, that provider is unavailable."
}

To fix this issue:
1. ${
params.apiKeyName
? `Set ${chalk.dim(
params.apiKeyName,
)} in your environment variables`
: "Set the environment variable for your provider (if required)"
}, or
2. Remove the ${chalk.italic(
"provider",
)} node from your i18n.json configuration to switch to ${chalk.hex(
colors.green,
)("Lingo.dev")}

${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")}
`,
);
}

const model = params.factory(
skipAuth ? {} : { apiKey, baseUrl: params.baseUrl },
);
const { model } = params;

return {
id: params.id,
Expand Down
90 changes: 24 additions & 66 deletions packages/cli/src/cli/processor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import dedent from "dedent";
import { LocalizerFn } from "./_base";
import { createLingoLocalizer } from "./lingo";
import { createBasicTranslator } from "./basic";
import { createOpenAI } from "@ai-sdk/openai";
import { colors } from "../constants";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { createMistral } from "@ai-sdk/mistral";
import { createOllama } from "ollama-ai-provider";
import {
createProviderClient,
ProviderKeyMissingError,
PROVIDER_METADATA,
SUPPORTED_PROVIDERS,
type ProviderId,
} from "@lingo.dev/providers";

export default function createProcessor(
provider: I18nConfig["provider"],
Expand Down Expand Up @@ -68,66 +69,23 @@ function getPureModelProvider(provider: I18nConfig["provider"]) {
${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")}
`;

switch (provider?.id) {
case "openai": {
if (!process.env.OPENAI_API_KEY) {
throw new Error(
createMissingKeyErrorMessage("OpenAI", "OPENAI_API_KEY"),
);
}
return createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: provider.baseUrl,
})(provider.model);
}
case "anthropic": {
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error(
createMissingKeyErrorMessage("Anthropic", "ANTHROPIC_API_KEY"),
);
}
return createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})(provider.model);
}
case "google": {
if (!process.env.GOOGLE_API_KEY) {
throw new Error(
createMissingKeyErrorMessage("Google", "GOOGLE_API_KEY"),
);
}
return createGoogleGenerativeAI({
apiKey: process.env.GOOGLE_API_KEY,
})(provider.model);
}
case "openrouter": {
if (!process.env.OPENROUTER_API_KEY) {
throw new Error(
createMissingKeyErrorMessage("OpenRouter", "OPENROUTER_API_KEY"),
);
}
return createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
baseURL: provider.baseUrl,
})(provider.model);
}
case "ollama": {
// No API key check needed for Ollama
return createOllama()(provider.model);
}
case "mistral": {
if (!process.env.MISTRAL_API_KEY) {
throw new Error(
createMissingKeyErrorMessage("Mistral", "MISTRAL_API_KEY"),
);
}
return createMistral({
apiKey: process.env.MISTRAL_API_KEY,
baseURL: provider.baseUrl,
})(provider.model);
}
default: {
throw new Error(createUnsupportedProviderErrorMessage(provider?.id));
const supported = new Set(SUPPORTED_PROVIDERS as readonly string[]);

if (!supported.has(provider?.id as any)) {
throw new Error(createUnsupportedProviderErrorMessage(provider?.id));
}

try {
return createProviderClient(provider!.id as ProviderId, provider!.model, {
baseUrl: provider!.baseUrl,
});
} catch (error: unknown) {
if (error instanceof ProviderKeyMissingError) {
const meta = PROVIDER_METADATA[error.providerId];
throw new Error(
createMissingKeyErrorMessage(meta?.name ?? error.providerId, meta?.apiKeyEnvVar),
);
}
throw error as Error;
}
}
44 changes: 44 additions & 0 deletions packages/cli/src/cli/processor/providers-routing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// Mock providers factory to observe routing
vi.mock("@lingo.dev/providers", async (importActual) => {
const actual = await importActual<any>();
return {
...actual,
createProviderClient: vi.fn(() => ({} as any)),
};
});

describe("processor routes providers via factory", () => {
beforeEach(async () => {
const mod = await import("@lingo.dev/providers");
vi.mocked(mod.createProviderClient as any).mockClear();
});

it("accepts every SUPPORTED_PROVIDERS and calls createProviderClient", async () => {
const { SUPPORTED_PROVIDERS, createProviderClient } = await import(
"@lingo.dev/providers"
);
const createProcessor = (await import("./index")).default;

for (const providerId of SUPPORTED_PROVIDERS) {
vi.mocked(createProviderClient as any).mockClear();

const processor = createProcessor(
{
id: providerId as any,
model: "test-model",
prompt: "test",
} as any,
{ apiUrl: "http://localhost" },
);

expect(typeof processor).toBe("function");
expect(createProviderClient).toHaveBeenCalledWith(
providerId,
"test-model",
expect.any(Object),
);
}
});
});
Loading
Loading