diff --git a/packages/cli/package.json b/packages/cli/package.json index de68d1bc0..c571f94dd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/packages/cli/src/cli/localizer/explicit.ts b/packages/cli/src/cli/localizer/explicit.ts index c055f56e3..882b90906 100644 --- a/packages/cli/src/cli/localizer/explicit.ts +++ b/packages/cli/src/cli/localizer/explicit.ts @@ -1,8 +1,3 @@ -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"; @@ -10,135 +5,80 @@ 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, ): 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["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, diff --git a/packages/cli/src/cli/processor/index.ts b/packages/cli/src/cli/processor/index.ts index 3f898d04a..c7e39d22e 100644 --- a/packages/cli/src/cli/processor/index.ts +++ b/packages/cli/src/cli/processor/index.ts @@ -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"], @@ -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; } } diff --git a/packages/cli/src/cli/processor/providers-routing.spec.ts b/packages/cli/src/cli/processor/providers-routing.spec.ts new file mode 100644 index 000000000..5862cffe2 --- /dev/null +++ b/packages/cli/src/cli/processor/providers-routing.spec.ts @@ -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(); + 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), + ); + } + }); +}); diff --git a/packages/cli/src/cli/utils/settings.ts b/packages/cli/src/cli/utils/settings.ts index 8d3db2928..3955cb12d 100644 --- a/packages/cli/src/cli/utils/settings.ts +++ b/packages/cli/src/cli/utils/settings.ts @@ -3,7 +3,8 @@ import path from "path"; import _ from "lodash"; import Z from "zod"; import fs from "fs"; -import Ini from "ini"; +import { getRcConfig, saveRcConfig } from "@lingo.dev/config"; +import { PROVIDER_METADATA } from "@lingo.dev/providers"; export type CliSettings = Z.infer; @@ -16,6 +17,18 @@ export function getSettings(explicitApiKey: string | undefined): CliSettings { _envVarsInfo(); + const llm: Record = {}; + for (const meta of Object.values(PROVIDER_METADATA)) { + const envVar = meta.apiKeyEnvVar; + const cfgKey = meta.apiKeyConfigKey; + if (!envVar || !cfgKey) continue; + const suffix = cfgKey.startsWith("llm.") ? cfgKey.slice(4) : undefined; + if (!suffix) continue; + const envVal = (env as any)[envVar] as string | undefined; + const rcVal = (systemFile.llm as any)?.[suffix] as string | undefined; + llm[suffix] = envVal || rcVal; + } + return { auth: { apiKey: @@ -32,15 +45,7 @@ export function getSettings(explicitApiKey: string | undefined): CliSettings { systemFile.auth?.webUrl || defaults.auth.webUrl, }, - llm: { - openaiApiKey: env.OPENAI_API_KEY || systemFile.llm?.openaiApiKey, - anthropicApiKey: env.ANTHROPIC_API_KEY || systemFile.llm?.anthropicApiKey, - groqApiKey: env.GROQ_API_KEY || systemFile.llm?.groqApiKey, - googleApiKey: env.GOOGLE_API_KEY || systemFile.llm?.googleApiKey, - openrouterApiKey: - env.OPENROUTER_API_KEY || systemFile.llm?.openrouterApiKey, - mistralApiKey: env.MISTRAL_API_KEY || systemFile.llm?.mistralApiKey, - }, + llm, }; } @@ -52,35 +57,38 @@ export function loadSystemSettings() { return _loadSystemFile(); } -const flattenZodObject = (schema: Z.ZodObject, prefix = ""): string[] => { - return Object.entries(schema.shape).flatMap(([key, value]) => { - const newPrefix = prefix ? `${prefix}.${key}` : key; - if (value instanceof Z.ZodObject) { - return flattenZodObject(value, newPrefix); - } - return [newPrefix]; - }); -}; - const SettingsSchema = Z.object({ auth: Z.object({ apiKey: Z.string(), apiUrl: Z.string(), webUrl: Z.string(), }), - llm: Z.object({ - openaiApiKey: Z.string().optional(), - anthropicApiKey: Z.string().optional(), - groqApiKey: Z.string().optional(), - googleApiKey: Z.string().optional(), - openrouterApiKey: Z.string().optional(), - mistralApiKey: Z.string().optional(), - }), + // Allow dynamic llm provider keys + llm: Z.record(Z.string().optional()), }); -export const SETTINGS_KEYS = flattenZodObject( - SettingsSchema, -) as readonly string[]; +function _providerConfigKeys(): string[] { + const keys: string[] = []; + for (const m of Object.values(PROVIDER_METADATA)) { + if (m.apiKeyConfigKey) keys.push(m.apiKeyConfigKey); + } + return keys; +} + +function _providerEnvVarKeys(): string[] { + const keys: string[] = []; + for (const m of Object.values(PROVIDER_METADATA)) { + if (m.apiKeyEnvVar) keys.push(m.apiKeyEnvVar); + } + return keys; +} + +export const SETTINGS_KEYS = [ + "auth.apiKey", + "auth.apiUrl", + "auth.webUrl", + ..._providerConfigKeys(), +] as string[]; // Private @@ -96,51 +104,34 @@ function _loadDefaults(): CliSettings { } function _loadEnv() { - return Z.object({ + const shape: Record = { LINGODOTDEV_API_KEY: Z.string().optional(), LINGODOTDEV_API_URL: Z.string().optional(), LINGODOTDEV_WEB_URL: Z.string().optional(), - OPENAI_API_KEY: Z.string().optional(), - ANTHROPIC_API_KEY: Z.string().optional(), - GROQ_API_KEY: Z.string().optional(), - GOOGLE_API_KEY: Z.string().optional(), - OPENROUTER_API_KEY: Z.string().optional(), - MISTRAL_API_KEY: Z.string().optional(), - }) - .passthrough() - .parse(process.env); + }; + for (const envVar of _providerEnvVarKeys()) { + shape[envVar] = Z.string().optional(); + } + return Z.object(shape).passthrough().parse(process.env); } function _loadSystemFile() { - const settingsFilePath = _getSettingsFilePath(); - const content = fs.existsSync(settingsFilePath) - ? fs.readFileSync(settingsFilePath, "utf-8") - : ""; - const data = Ini.parse(content); - + const data = getRcConfig(); return Z.object({ auth: Z.object({ apiKey: Z.string().optional(), apiUrl: Z.string().optional(), webUrl: Z.string().optional(), }).optional(), - llm: Z.object({ - openaiApiKey: Z.string().optional(), - anthropicApiKey: Z.string().optional(), - groqApiKey: Z.string().optional(), - googleApiKey: Z.string().optional(), - openrouterApiKey: Z.string().optional(), - mistralApiKey: Z.string().optional(), - }).optional(), + // Accept any llm provider key from rc file + llm: Z.record(Z.string().optional()).optional(), }) .passthrough() .parse(data); } function _saveSystemFile(settings: CliSettings) { - const settingsFilePath = _getSettingsFilePath(); - const content = Ini.stringify(settings); - fs.writeFileSync(settingsFilePath, content); + saveRcConfig(settings); } function _getSettingsFilePath(): string { @@ -177,41 +168,20 @@ function _envVarsInfo() { `ℹ️ Using LINGODOTDEV_API_KEY env var instead of credentials from user config`, ); } - if (env.OPENAI_API_KEY && systemFile.llm?.openaiApiKey) { - console.info( - "\x1b[36m%s\x1b[0m", - `ℹ️ Using OPENAI_API_KEY env var instead of key from user config.`, - ); - } - if (env.ANTHROPIC_API_KEY && systemFile.llm?.anthropicApiKey) { - console.info( - "\x1b[36m%s\x1b[0m", - `ℹ️ Using ANTHROPIC_API_KEY env var instead of key from user config`, - ); - } - if (env.GROQ_API_KEY && systemFile.llm?.groqApiKey) { - console.info( - "\x1b[36m%s\x1b[0m", - `ℹ️ Using GROQ_API_KEY env var instead of key from user config`, - ); - } - if (env.GOOGLE_API_KEY && systemFile.llm?.googleApiKey) { - console.info( - "\x1b[36m%s\x1b[0m", - `ℹ️ Using GOOGLE_API_KEY env var instead of key from user config`, - ); - } - if (env.OPENROUTER_API_KEY && systemFile.llm?.openrouterApiKey) { - console.info( - "\x1b[36m%s\x1b[0m", - `ℹ️ Using OPENROUTER_API_KEY env var instead of key from user config`, - ); - } - if (env.MISTRAL_API_KEY && systemFile.llm?.mistralApiKey) { - console.info( - "\x1b[36m%s\x1b[0m", - `ℹ️ Using MISTRAL_API_KEY env var instead of key from user config`, - ); + // Provider-specific env vs rc info using shared metadata + for (const meta of Object.values(PROVIDER_METADATA)) { + const envVar = meta.apiKeyEnvVar; + const cfgKey = meta.apiKeyConfigKey; + if (!envVar || !cfgKey) continue; + const cfgSuffix = cfgKey.startsWith("llm.") ? cfgKey.slice(4) : undefined; + const envVal = (env as any)[envVar]; + const rcVal = cfgSuffix ? (systemFile.llm as any)?.[cfgSuffix] : undefined; + if (envVal && rcVal) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using ${envVar} env var instead of key from user config`, + ); + } } if (env.LINGODOTDEV_API_URL) { console.info( diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 946650444..504b97139 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -38,16 +38,16 @@ "vitest": "^2.1.4" }, "dependencies": { - "@ai-sdk/google": "^1.2.19", - "@ai-sdk/groq": "^1.2.3", - "@ai-sdk/mistral": "^1.2.8", + "@lingo.dev/providers": "workspace:*", + "@lingo.dev/config": "workspace:*", + "@babel/generator": "^7.26.5", "@babel/parser": "^7.26.7", "@babel/traverse": "^7.27.4", "@babel/types": "^7.26.7", "@lingo.dev/_sdk": "workspace:*", "@lingo.dev/_spec": "workspace:*", - "@openrouter/ai-sdk-provider": "^0.7.1", + "@prettier/sync": "^0.6.1", "ai": "^4.2.10", "dedent": "^1.6.0", @@ -56,7 +56,7 @@ "ini": "^5.0.0", "lodash": "^4.17.21", "object-hash": "^3.0.0", - "ollama-ai-provider": "^1.2.0", + "prettier": "^3.4.2", "unplugin": "^2.1.2", "zod": "^3.25.76", diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 94fcb6056..dcfdf89ef 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -7,45 +7,26 @@ import { defaultParams } from "./_base"; import { LCP_DICTIONARY_FILE_NAME } from "./_const"; import { LCPCache } from "./lib/lcp/cache"; import { getInvalidLocales } from "./utils/locales"; -import { - getGroqKeyFromEnv, - getGroqKeyFromRc, - getGoogleKeyFromEnv, - getGoogleKeyFromRc, - getMistralKeyFromEnv, - getMistralKeyFromRc, - getLingoDotDevKeyFromEnv, - getLingoDotDevKeyFromRc, -} from "./utils/llm-api-key"; import { isRunningInCIOrDocker } from "./utils/env"; -import { providerDetails } from "./lib/lcp/api/provider-details"; import { loadDictionary, transformComponent } from "./_loader-utils"; import trackEvent from "./utils/observability"; +import { PROVIDER_METADATA, resolveProviderApiKey } from "@lingo.dev/providers"; +import { getRcConfig } from "@lingo.dev/config"; -const keyCheckers: Record< - string, - { - checkEnv: () => string | undefined; - checkRc: () => string | undefined; +function getProviderDetails(providerId: string) { + const meta = (PROVIDER_METADATA as any)[providerId]; + if (meta) return meta; + if (providerId === "lingo.dev") { + return { + name: "Lingo.dev", + apiKeyEnvVar: "LINGODOTDEV_API_KEY", + apiKeyConfigKey: "auth.apiKey", + getKeyLink: "https://lingo.dev", + docsLink: "https://lingo.dev/docs", + }; } -> = { - groq: { - checkEnv: getGroqKeyFromEnv, - checkRc: getGroqKeyFromRc, - }, - google: { - checkEnv: getGoogleKeyFromEnv, - checkRc: getGoogleKeyFromRc, - }, - mistral: { - checkEnv: getMistralKeyFromEnv, - checkRc: getMistralKeyFromRc, - }, - "lingo.dev": { - checkEnv: getLingoDotDevKeyFromEnv, - checkRc: getLingoDotDevKeyFromRc, - }, -}; + return undefined; +} const alreadySentBuildEvent = { value: false }; @@ -347,9 +328,7 @@ function getConfiguredProviders(models: Record): string[] { .filter(Boolean) // Remove empty strings .uniq() // Get unique providers .filter( - (providerId) => - providerDetails.hasOwnProperty(providerId) && - keyCheckers.hasOwnProperty(providerId), + (providerId) => !!(PROVIDER_METADATA as any)[providerId], ) // Only check for known and implemented providers .value(); } @@ -371,19 +350,37 @@ function validateLLMKeyDetails(configuredProviders: string[]): void { { foundInEnv: boolean; foundInRc: boolean; - details: (typeof providerDetails)[string]; + details: ReturnType; } > = {}; const missingProviders: string[] = []; const foundProviders: string[] = []; for (const providerId of configuredProviders) { - const details = providerDetails[providerId]; - const checkers = keyCheckers[providerId]; - if (!details || !checkers) continue; // Should not happen due to filter above + const details = getProviderDetails(providerId); + if (!details) continue; - const foundInEnv = !!checkers.checkEnv(); - const foundInRc = !!checkers.checkRc(); + const foundInEnv = (() => { + if (providerId === "lingo.dev") { + return !!process.env["LINGODOTDEV_API_KEY"]; + } + const envVar = details.apiKeyEnvVar; + if (!envVar) return false; + // Isolate env-only resolution + return !!resolveProviderApiKey(providerId as any, { + sources: { env: { [envVar]: process.env[envVar] }, rc: {} as any }, + }); + })(); + + const foundInRc = (() => { + const rc = getRcConfig(); + if (providerId === "lingo.dev") { + return typeof _.get(rc, "auth.apiKey") === "string"; + } + return !!resolveProviderApiKey(providerId as any, { + sources: { env: {}, rc: rc as any }, + }); + })(); keyStatuses[providerId] = { foundInEnv, foundInRc, details }; diff --git a/packages/compiler/src/lib/lcp/api/index.ts b/packages/compiler/src/lib/lcp/api/index.ts index 0c7cf5541..d8a47cec4 100644 --- a/packages/compiler/src/lib/lcp/api/index.ts +++ b/packages/compiler/src/lib/lcp/api/index.ts @@ -1,8 +1,3 @@ -import { createGroq } from "@ai-sdk/groq"; -import { createGoogleGenerativeAI } from "@ai-sdk/google"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; -import { createOllama } from "ollama-ai-provider"; -import { createMistral } from "@ai-sdk/mistral"; import { generateText } from "ai"; import { LingoDotDevEngine } from "@lingo.dev/_sdk"; import { DictionarySchema } from "../schema"; @@ -11,22 +6,19 @@ import { getLocaleModel } from "../../../utils/locales"; import getSystemPrompt from "./prompt"; import { obj2xml, xml2obj } from "./xml2obj"; import shots from "./shots"; -import { - getGroqKey, - getGroqKeyFromEnv, - getGoogleKey, - getGoogleKeyFromEnv, - getOpenRouterKey, - getOpenRouterKeyFromEnv, - getMistralKey, - getMistralKeyFromEnv, - getLingoDotDevKeyFromEnv, - getLingoDotDevKey, -} from "../../../utils/llm-api-key"; import dedent from "dedent"; import { isRunningInCIOrDocker } from "../../../utils/env"; import { LanguageModel } from "ai"; -import { providerDetails } from "./provider-details"; +import { + createProviderClient, + ProviderKeyMissingError, + PROVIDER_METADATA, + type ProviderId, +} from "@lingo.dev/providers"; +import * as dotenv from "dotenv"; +import path from "path"; +import fs from "fs"; +import { getRcConfig } from "@lingo.dev/config"; export class LCPAPI { static async translate( @@ -139,14 +131,35 @@ export class LCPAPI { } private static _createLingoDotDevEngine() { - // Specific check for CI/CD or Docker missing GROQ key + const getEnvWithDotenv = (name: string): string | undefined => { + if (process.env[name]) return process.env[name]; + const candidates = [ + path.resolve(process.cwd(), ".env"), + path.resolve(process.cwd(), ".env.local"), + path.resolve(process.cwd(), ".env.development"), + ]; + for (const file of candidates) { + if (!fs.existsSync(file)) continue; + const result = dotenv.config({ path: file }); + if (process.env[name]) return process.env[name]; + if (result?.parsed?.[name]) return result.parsed[name]; + } + return undefined; + }; + if (isRunningInCIOrDocker()) { - const apiKeyFromEnv = getLingoDotDevKeyFromEnv(); + const apiKeyFromEnv = getEnvWithDotenv("LINGODOTDEV_API_KEY"); if (!apiKeyFromEnv) { this._failMissingLLMKeyCi("lingo.dev"); } } - const apiKey = getLingoDotDevKey(); + const apiKey = + getEnvWithDotenv("LINGODOTDEV_API_KEY") || + (() => { + const rc = getRcConfig(); + const val = _.get(rc, "auth.apiKey"); + return typeof val === "string" ? val : undefined; + })(); if (!apiKey) { throw new Error( "⚠️ Lingo.dev API key not found. Please set LINGODOTDEV_API_KEY environment variable or configure it user-wide.", @@ -223,7 +236,24 @@ export class LCPAPI { } try { - const aiModel = this._createAiModel(provider, model, targetLocale); + const aiModel = ((): LanguageModel => { + try { + return createProviderClient(provider as ProviderId, model); + } catch (error: unknown) { + if (error instanceof ProviderKeyMissingError) { + if (isRunningInCIOrDocker()) { + this._failMissingLLMKeyCi(error.providerId); + } else { + this._failLLMFailureLocal( + provider, + targetLocale, + error.message, + ); + } + } + throw error as Error; + } + })(); console.log( `ℹ️ Using raw LLM API ("${provider}":"${model}") to translate from "${sourceLocale}" to "${targetLocale}"`, @@ -279,116 +309,21 @@ export class LCPAPI { } } - /** - * Instantiates an AI model based on provider and model ID. - * Includes CI/CD API key checks. - * @param providerId The ID of the AI provider (e.g., "groq", "google"). - * @param modelId The ID of the specific model (e.g., "llama3-8b-8192", "gemini-2.0-flash"). - * @param targetLocale The target locale being translated to (for logging/error messages). - * @returns An instantiated AI LanguageModel. - * @throws Error if the provider is not supported or API key is missing in CI/CD. - */ - private static _createAiModel( - providerId: string, - modelId: string, - targetLocale: string, - ): LanguageModel { - switch (providerId) { - case "groq": { - // Specific check for CI/CD or Docker missing GROQ key - if (isRunningInCIOrDocker()) { - const groqFromEnv = getGroqKeyFromEnv(); - if (!groqFromEnv) { - this._failMissingLLMKeyCi(providerId); - } - } - const groqKey = getGroqKey(); - if (!groqKey) { - throw new Error( - "⚠️ GROQ API key not found. Please set GROQ_API_KEY environment variable or configure it user-wide.", - ); - } - console.log( - `Creating Groq client for ${targetLocale} using model ${modelId}`, - ); - return createGroq({ apiKey: groqKey })(modelId); - } - - case "google": { - // Specific check for CI/CD or Docker missing Google key - if (isRunningInCIOrDocker()) { - const googleFromEnv = getGoogleKeyFromEnv(); - if (!googleFromEnv) { - this._failMissingLLMKeyCi(providerId); - } - } - const googleKey = getGoogleKey(); - if (!googleKey) { - throw new Error( - "⚠️ Google API key not found. Please set GOOGLE_API_KEY environment variable or configure it user-wide.", - ); - } - console.log( - `Creating Google Generative AI client for ${targetLocale} using model ${modelId}`, - ); - return createGoogleGenerativeAI({ apiKey: googleKey })(modelId); - } - case "openrouter": { - // Specific check for CI/CD or Docker missing OpenRouter key - if (isRunningInCIOrDocker()) { - const openRouterFromEnv = getOpenRouterKeyFromEnv(); - if (!openRouterFromEnv) { - this._failMissingLLMKeyCi(providerId); - } - } - const openRouterKey = getOpenRouterKey(); - if (!openRouterKey) { - throw new Error( - "⚠️ OpenRouter API key not found. Please set OPENROUTER_API_KEY environment variable or configure it user-wide.", - ); - } - console.log( - `Creating OpenRouter client for ${targetLocale} using model ${modelId}`, - ); - return createOpenRouter({ - apiKey: openRouterKey, - })(modelId); - } - - case "ollama": { - // No API key check needed for Ollama - console.log( - `Creating Ollama client for ${targetLocale} using model ${modelId} at default Ollama address`, - ); - return createOllama()(modelId); - } - - case "mistral": { - // Specific check for CI/CD or Docker missing Mistral key - if (isRunningInCIOrDocker()) { - const mistralFromEnv = getMistralKeyFromEnv(); - if (!mistralFromEnv) { - this._failMissingLLMKeyCi(providerId); - } - } - const mistralKey = getMistralKey(); - if (!mistralKey) { - throw new Error( - "⚠️ Mistral API key not found. Please set MISTRAL_API_KEY environment variable or configure it user-wide.", - ); - } - console.log( - `Creating Mistral client for ${targetLocale} using model ${modelId}`, - ); - return createMistral({ apiKey: mistralKey })(modelId); - } - - default: { - throw new Error( - `⚠️ Provider "${providerId}" for locale "${targetLocale}" is not supported. Only "groq", "google", "openrouter", "ollama", and "mistral" providers are supported at the moment.`, - ); - } + // details lookup compatible with providers metadata + lingo.dev special-case + private static _getProviderDetails(providerId: string) { + if ((PROVIDER_METADATA as any)[providerId]) { + return (PROVIDER_METADATA as any)[providerId]; + } + if (providerId === "lingo.dev") { + return { + name: "Lingo.dev", + apiKeyEnvVar: "LINGODOTDEV_API_KEY", + apiKeyConfigKey: "auth.apiKey", + getKeyLink: "https://lingo.dev", + docsLink: "https://lingo.dev/docs", + }; } + return undefined; } /** @@ -398,7 +333,7 @@ export class LCPAPI { * @param providerId The ID of the LLM provider whose key is missing. */ private static _failMissingLLMKeyCi(providerId: string): void { - let details = providerDetails[providerId]; + let details = this._getProviderDetails(providerId); if (!details) { // Fallback for unsupported provider in failure message logic console.error( @@ -443,7 +378,7 @@ export class LCPAPI { targetLocale: string, errorMessage: string, ): void { - const details = providerDetails[providerId]; + const details = this._getProviderDetails(providerId); if (!details) { // Fallback console.error( diff --git a/packages/compiler/src/lib/lcp/api/provider-details.spec.ts b/packages/compiler/src/lib/lcp/api/provider-details.spec.ts deleted file mode 100644 index 79f3a79d8..000000000 --- a/packages/compiler/src/lib/lcp/api/provider-details.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { providerDetails } from "./provider-details"; - -describe("provider-details", () => { - it("should provide data for all supported providers", () => { - expect(Object.keys(providerDetails)).toEqual([ - "groq", - "google", - "openrouter", - "ollama", - "mistral", - "lingo.dev", - ]); - }); -}); diff --git a/packages/compiler/src/lib/lcp/api/providers-routing.spec.ts b/packages/compiler/src/lib/lcp/api/providers-routing.spec.ts new file mode 100644 index 000000000..586109649 --- /dev/null +++ b/packages/compiler/src/lib/lcp/api/providers-routing.spec.ts @@ -0,0 +1,68 @@ +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(); + return { + ...actual, + createProviderClient: vi.fn(() => ({} as any)), + }; +}); + +// Mock AI SDK generateText to return the last message content (XML) +vi.mock("ai", () => { + return { + generateText: vi.fn((args: any) => { + const last = args.messages[args.messages.length - 1]; + return { text: last.content } as any; + }), + // Provide a dummy export to satisfy named import in source + LanguageModel: class {}, + }; +}); + +describe("LCPAPI 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 { LCPAPI } = await import("./index"); + + for (const providerId of SUPPORTED_PROVIDERS) { + vi.mocked(createProviderClient as any).mockClear(); + + const models = { "*:*": `${providerId}:dummy-model` } as Record< + string, + string + >; + + // Minimal dictionary + const dictionary = { + version: 0.1, + locale: "en", + files: { + "a.json": { entries: { hello: "Hello" } }, + }, + } as const; + + const result = await LCPAPI.translate( + models, + dictionary as any, + "en", + "es", + null, + ); + + expect(result).toBeTruthy(); + // In compiler LCP path, factory is called with (providerId, model) only + const call = vi.mocked(createProviderClient as any).mock.calls[0]; + expect(call[0]).toBe(providerId); + expect(call[1]).toBe("dummy-model"); + } + }); +}); diff --git a/packages/compiler/src/utils/llm-api-key.ts b/packages/compiler/src/utils/llm-api-key.ts deleted file mode 100644 index bf1b243ad..000000000 --- a/packages/compiler/src/utils/llm-api-key.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { getRc } from "./rc"; -import _ from "lodash"; -import * as dotenv from "dotenv"; -import path from "path"; - -// Generic function to retrieve key from process.env, with .env file as fallback -export function getKeyFromEnv(envVarName: string): string | undefined { - if (process.env[envVarName]) { - return process.env[envVarName]; - } - const result = dotenv.config({ - path: [ - path.resolve(process.cwd(), ".env"), - path.resolve(process.cwd(), ".env.local"), - path.resolve(process.cwd(), ".env.development"), - ], - }); - return result?.parsed?.[envVarName]; -} - -// Generic function to retrieve key from .lingodotdevrc file -function getKeyFromRc(rcPath: string): string | undefined { - const rc = getRc(); - const result = _.get(rc, rcPath); - return typeof result === "string" ? result : undefined; -} - -export function getGroqKey() { - return getGroqKeyFromEnv() || getGroqKeyFromRc(); -} - -export function getGroqKeyFromRc() { - return getKeyFromRc("llm.groqApiKey"); -} - -export function getGroqKeyFromEnv() { - return getKeyFromEnv("GROQ_API_KEY"); -} - -export function getLingoDotDevKeyFromEnv() { - return getKeyFromEnv("LINGODOTDEV_API_KEY"); -} - -export function getLingoDotDevKeyFromRc() { - return getKeyFromRc("auth.apiKey"); -} - -export function getLingoDotDevKey() { - return getLingoDotDevKeyFromEnv() || getLingoDotDevKeyFromRc(); -} - -export function getGoogleKey() { - return getGoogleKeyFromEnv() || getGoogleKeyFromRc(); -} - -export function getGoogleKeyFromRc() { - return getKeyFromRc("llm.googleApiKey"); -} - -export function getGoogleKeyFromEnv() { - return getKeyFromEnv("GOOGLE_API_KEY"); -} - -export function getOpenRouterKey() { - return getOpenRouterKeyFromEnv() || getOpenRouterKeyFromRc(); -} -export function getOpenRouterKeyFromRc() { - return getKeyFromRc("llm.openrouterApiKey"); -} -export function getOpenRouterKeyFromEnv() { - return getKeyFromEnv("OPENROUTER_API_KEY"); -} - -export function getMistralKey() { - return getMistralKeyFromEnv() || getMistralKeyFromRc(); -} - -export function getMistralKeyFromRc() { - return getKeyFromRc("llm.mistralApiKey"); -} - -export function getMistralKeyFromEnv() { - return getKeyFromEnv("MISTRAL_API_KEY"); -} diff --git a/packages/compiler/src/utils/llm-api-keys.spec.ts b/packages/compiler/src/utils/llm-api-keys.spec.ts deleted file mode 100644 index c6c6b5ca8..000000000 --- a/packages/compiler/src/utils/llm-api-keys.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import * as dotenv from "dotenv"; -import * as path from "path"; -import { getKeyFromEnv } from "./llm-api-key"; - -const ORIGINAL_ENV = { ...process.env }; - -vi.mock("dotenv"); - -describe("LLM API keys", () => { - describe("getKeyFromEnv", () => { - beforeEach(() => { - vi.resetModules(); - process.env = { ...ORIGINAL_ENV }; - }); - - afterEach(() => { - process.env = { ...ORIGINAL_ENV }; - vi.restoreAllMocks(); - }); - - it("returns API key from process.env if set", () => { - process.env.FOOBAR_API_KEY = "env-key"; - expect(getKeyFromEnv("FOOBAR_API_KEY")).toBe("env-key"); - }); - - it("returns API key from .env file if not in process.env", () => { - delete process.env.FOOBAR_API_KEY; - const fakeEnv = { FOOBAR_API_KEY: "file-key" }; - const configMock = vi - .mocked(dotenv.config) - .mockImplementation((opts: any) => { - if (opts && opts.processEnv) { - Object.assign(opts.processEnv, fakeEnv); - } - return { parsed: fakeEnv }; - }); - expect(getKeyFromEnv("FOOBAR_API_KEY")).toBe("file-key"); - expect(configMock).toHaveBeenCalledWith({ - path: [ - path.resolve(process.cwd(), ".env"), - path.resolve(process.cwd(), ".env.local"), - path.resolve(process.cwd(), ".env.development"), - ], - }); - }); - - it("returns undefined if no GROQ_API_KEY in env or .env file", () => { - delete process.env.GROQ_API_KEY; - vi.mocked(dotenv.config).mockResolvedValue({ parsed: {} }); - expect(getKeyFromEnv("FOOBAR_API_KEY")).toBeUndefined(); - }); - }); -}); diff --git a/packages/compiler/src/utils/observability.ts b/packages/compiler/src/utils/observability.ts index 07935e7c8..adedec2a2 100644 --- a/packages/compiler/src/utils/observability.ts +++ b/packages/compiler/src/utils/observability.ts @@ -1,5 +1,5 @@ import { machineId } from "node-machine-id"; -import { getRc } from "./rc"; +import { getRcConfig } from "@lingo.dev/config"; export default async function trackEvent( event: string, @@ -44,7 +44,7 @@ export default async function trackEvent( } async function getActualId() { - const rc = getRc(); + const rc = getRcConfig(); const apiKey = process.env.LINGODOTDEV_API_KEY || rc?.auth?.apiKey; const apiUrl = process.env.LINGODOTDEV_API_URL || diff --git a/packages/compiler/src/utils/rc.spec.ts b/packages/compiler/src/utils/rc.spec.ts deleted file mode 100644 index 106a44fbb..000000000 --- a/packages/compiler/src/utils/rc.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getRc } from "./rc"; - -vi.mock("os", () => ({ default: { homedir: () => "/home/test" } })); -vi.mock("fs", () => { - const mockFs = { - existsSync: vi.fn(() => false), - readFileSync: vi.fn(() => ""), - } as any; - return { ...mockFs, default: mockFs }; -}); - -import fsAny from "fs"; - -describe("getRc", () => { - beforeEach(() => { - (fsAny as any).existsSync.mockReset().mockReturnValue(false); - (fsAny as any).readFileSync.mockReset().mockReturnValue(""); - }); - - it("returns empty object when rc file missing", () => { - const data = getRc(); - expect(data).toEqual({}); - }); - - it("parses ini file when present", () => { - (fsAny as any).existsSync.mockReturnValue(true); - (fsAny as any).readFileSync.mockReturnValue("[auth]\napiKey=abc\n"); - const data = getRc(); - expect(data).toHaveProperty("auth.apiKey", "abc"); - }); -}); diff --git a/packages/compiler/src/utils/rc.ts b/packages/compiler/src/utils/rc.ts deleted file mode 100644 index fb4f9d92c..000000000 --- a/packages/compiler/src/utils/rc.ts +++ /dev/null @@ -1,15 +0,0 @@ -import os from "os"; -import path from "path"; -import fs from "fs"; -import Ini from "ini"; - -export function getRc() { - const settingsFile = ".lingodotdevrc"; - const homedir = os.homedir(); - const settingsFilePath = path.join(homedir, settingsFile); - const content = fs.existsSync(settingsFilePath) - ? fs.readFileSync(settingsFilePath, "utf-8") - : ""; - const data = Ini.parse(content); - return data; -} diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 000000000..7b226c154 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,41 @@ +{ + "name": "@lingo.dev/config", + "version": "0.1.0", + "description": "Lingo.dev user configuration (.lingodotdevrc) reader and schema", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "sideEffects": false, + "main": "build/index.cjs", + "module": "build/index.mjs", + "types": "build/index.d.ts", + "files": [ + "build" + ], + "scripts": { + "dev": "tsup --watch", + "build": "pnpm typecheck && tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "ini": "^5.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/ini": "^4.1.1", + "@types/node": "^22.13.5", + "tsup": "^8.3.5", + "typescript": "^5.8.3", + "vitest": "^3.1.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/config/src/index.spec.ts b/packages/config/src/index.spec.ts new file mode 100644 index 000000000..73720dbd2 --- /dev/null +++ b/packages/config/src/index.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { saveRcConfig, getRcConfig } from "./index"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +vi.mock("fs"); +vi.mock("os"); + +describe("config package", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(os.homedir).mockReturnValue("/mock/home"); + }); + + it("saveRcConfig uses Ini.stringify", () => { + const mockWrite = vi.mocked(fs.writeFileSync); + + saveRcConfig({ + auth: { + apiKey: "test-key", + apiUrl: "https://test.com", + webUrl: "https://web.test.com", + }, + llm: { + openaiApiKey: "openai-key", + }, + }); + + expect(mockWrite).toHaveBeenCalledWith( + path.join("/mock/home", ".lingodotdevrc"), + expect.stringContaining("[auth]") + ); + expect(mockWrite).toHaveBeenCalledWith( + path.join("/mock/home", ".lingodotdevrc"), + expect.stringContaining("[llm]") + ); + }); + + it("saveRcConfig handles undefined values gracefully", () => { + const mockWrite = vi.mocked(fs.writeFileSync); + + saveRcConfig({ + auth: { + apiKey: "test-key", + }, + llm: { + openaiApiKey: undefined, + anthropicApiKey: "defined-key", + }, + }); + + expect(mockWrite).toHaveBeenCalled(); + const writtenContent = mockWrite.mock.calls[0][1] as string; + + // Ini.stringify should omit undefined values, not write "undefined" + expect(writtenContent).not.toContain("undefined"); + expect(writtenContent).toContain("anthropicApiKey"); + }); +}); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 000000000..34083fff2 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,50 @@ +import os from "os"; +import path from "path"; +import fs from "fs"; +import Ini from "ini"; +import Z from "zod"; + +export const rcConfigSchema = Z.object({ + auth: Z.object({ + apiKey: Z.string().optional(), + apiUrl: Z.string().optional(), + webUrl: Z.string().optional(), + }).optional(), + // Allow any llm provider keys and preserve them + llm: Z.record(Z.string().optional()).optional(), +}) + .passthrough(); + +export type RcConfig = Z.infer; + +const SETTINGS_FILE = ".lingodotdevrc"; + +function getSettingsFilePath(): string { + return path.join(os.homedir(), SETTINGS_FILE); +} + +export function getRcConfig(): RcConfig { + const settingsFilePath = getSettingsFilePath(); + const content = fs.existsSync(settingsFilePath) + ? fs.readFileSync(settingsFilePath, "utf-8") + : ""; + const data = Ini.parse(content); + return rcConfigSchema.parse(data); +} + +function removeUndefined(obj: T): T { + if (obj === null || typeof obj !== "object") return obj; + if (Array.isArray(obj)) return obj.map(removeUndefined) as T; + return Object.fromEntries( + Object.entries(obj) + .filter(([_, v]) => v !== undefined) + .map(([k, v]) => [k, removeUndefined(v)]) + ) as T; +} + +export function saveRcConfig(config: RcConfig): void { + const settingsFilePath = getSettingsFilePath(); + // Ini.stringify writes literal "undefined" strings, so filter them out first + const content = Ini.stringify(removeUndefined(config)); + fs.writeFileSync(settingsFilePath, content); +} diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 000000000..d7b0f86bc --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "allowUnreachableCode": true, + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/config/tsup.config.ts b/packages/config/tsup.config.ts new file mode 100644 index 000000000..2d13ece73 --- /dev/null +++ b/packages/config/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + target: "esnext", + entry: ["src/index.ts"], + outDir: "build", + format: ["cjs", "esm"], + dts: true, + cjsInterop: true, + splitting: true, + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), +}); diff --git a/packages/providers/package.json b/packages/providers/package.json new file mode 100644 index 000000000..5e98b5f75 --- /dev/null +++ b/packages/providers/package.json @@ -0,0 +1,49 @@ +{ + "name": "@lingo.dev/providers", + "version": "0.1.0", + "description": "Unified provider registry, API key resolution, and client factories for Lingo.dev", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "sideEffects": false, + "main": "build/index.cjs", + "module": "build/index.mjs", + "types": "build/index.d.ts", + "files": [ + "build" + ], + "scripts": { + "dev": "tsup --watch", + "build": "pnpm typecheck && tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@lingo.dev/config": "workspace:*", + "@ai-sdk/anthropic": "^1.2.11", + "@ai-sdk/google": "^1.2.19", + "@ai-sdk/groq": "^1.2.3", + "@ai-sdk/mistral": "^1.2.8", + "@ai-sdk/openai": "^1.3.22", + "@openrouter/ai-sdk-provider": "^0.7.1", + "ai": "^4.3.15", + "dotenv": "^16.4.7", + "ollama-ai-provider": "^1.2.0" + }, + "devDependencies": { + "@types/node": "^22.13.5", + "@types/ini": "^4.1.1", + "tsup": "^8.3.5", + "typescript": "^5.8.3", + "vitest": "^3.1.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/providers/src/errors.ts b/packages/providers/src/errors.ts new file mode 100644 index 000000000..082018d61 --- /dev/null +++ b/packages/providers/src/errors.ts @@ -0,0 +1,33 @@ +import { ProviderId } from "./metadata"; + +export class ProviderKeyMissingError extends Error { + constructor( + public providerId: ProviderId, + public envVar?: string, + public configKey?: string, + ) { + super( + `API key for provider "${providerId}" not found` + + (envVar ? ` (env: ${envVar})` : "") + + (configKey ? ` (rc: ${configKey})` : ""), + ); + this.name = "ProviderKeyMissingError"; + } +} + +export class ProviderAuthFailedError extends Error { + constructor( + public providerId: ProviderId, + public originalError: Error, + ) { + super(`Authentication failed for provider "${providerId}": ${originalError.message}`); + this.name = "ProviderAuthFailedError"; + } +} + +export class UnsupportedProviderError extends Error { + constructor(public providerId: string) { + super(`Unsupported provider: ${providerId}`); + this.name = "UnsupportedProviderError"; + } +} diff --git a/packages/providers/src/factory.spec.ts b/packages/providers/src/factory.spec.ts new file mode 100644 index 000000000..e891e77a3 --- /dev/null +++ b/packages/providers/src/factory.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { createProviderClient } from "./factory"; + +describe("createProviderClient", () => { + it("creates ollama client without api key (skipAuth)", () => { + const client = createProviderClient("ollama", "llama3"); + expect(client).toBeTruthy(); + }); + + it("creates openrouter client with explicit apiKey override", () => { + const client = createProviderClient("openrouter", "gpt-4o-mini", { + apiKey: "dummy", + }); + expect(client).toBeTruthy(); + }); +}); diff --git a/packages/providers/src/factory.ts b/packages/providers/src/factory.ts new file mode 100644 index 000000000..853499118 --- /dev/null +++ b/packages/providers/src/factory.ts @@ -0,0 +1,61 @@ +import { LanguageModel } from "ai"; +import { ProviderId } from "./metadata"; +import { resolveProviderApiKey } from "./keys"; +import { PROVIDER_METADATA } from "./metadata"; +import { UnsupportedProviderError } from "./errors"; +import { createOpenAI } from "@ai-sdk/openai"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { createOllama } from "ollama-ai-provider"; +import { createMistral } from "@ai-sdk/mistral"; +import { createGroq } from "@ai-sdk/groq"; + +export interface ClientOptions { + apiKey?: string; + baseUrl?: string; + skipAuth?: boolean; +} + +export function createProviderClient( + providerId: ProviderId, + modelId: string, + options?: ClientOptions, +): LanguageModel { + const skipAuth = + options?.skipAuth === true || !PROVIDER_METADATA[providerId]?.apiKeyEnvVar; + const apiKey = options?.apiKey ?? resolveProviderApiKey(providerId, { required: !skipAuth }); + + switch (providerId) { + case "openai": { + const client = createOpenAI({ apiKey, baseURL: options?.baseUrl }); + return client(modelId); + } + case "anthropic": { + const client = createAnthropic({ apiKey, baseURL: options?.baseUrl }); + return client(modelId); + } + case "google": { + const client = createGoogleGenerativeAI({ apiKey }); + return client(modelId); + } + case "openrouter": { + const client = createOpenRouter({ apiKey, baseURL: options?.baseUrl }); + return client(modelId); + } + case "ollama": { + const client = createOllama(); + return client(modelId); + } + case "mistral": { + const client = createMistral({ apiKey, baseURL: options?.baseUrl }); + return client(modelId); + } + case "groq": { + const client = createGroq({ apiKey }); + return client(modelId); + } + default: + throw new UnsupportedProviderError(providerId); + } +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts new file mode 100644 index 000000000..5831abd6c --- /dev/null +++ b/packages/providers/src/index.ts @@ -0,0 +1,19 @@ +export { + SUPPORTED_PROVIDERS, + type ProviderId, + type ProviderMetadata, + PROVIDER_METADATA, +} from "./metadata"; +export { + resolveProviderApiKey, + type KeySources, +} from "./keys"; +export { + ProviderKeyMissingError, + ProviderAuthFailedError, + UnsupportedProviderError, +} from "./errors"; +export { + createProviderClient, + type ClientOptions, +} from "./factory"; diff --git a/packages/providers/src/keys.spec.ts b/packages/providers/src/keys.spec.ts new file mode 100644 index 000000000..07e072ee2 --- /dev/null +++ b/packages/providers/src/keys.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { resolveProviderApiKey } from "./keys"; +import { ProviderKeyMissingError } from "./errors"; + +describe("resolveProviderApiKey", () => { + it("prefers env over rc when both provided", () => { + const key = resolveProviderApiKey("openai", { + sources: { + env: { OPENAI_API_KEY: "env-key" }, + rc: { llm: { openaiApiKey: "rc-key" } }, + }, + }); + expect(key).toBe("env-key"); + }); + + it("throws when required and key missing", () => { + expect(() => + resolveProviderApiKey("mistral", { + sources: { env: {}, rc: {} }, + required: true, + }), + ).toThrow(ProviderKeyMissingError); + }); +}); diff --git a/packages/providers/src/keys.ts b/packages/providers/src/keys.ts new file mode 100644 index 000000000..7dbded5da --- /dev/null +++ b/packages/providers/src/keys.ts @@ -0,0 +1,64 @@ +import path from "path"; +import fs from "fs"; +import dotenv from "dotenv"; +import { ProviderId } from "./metadata"; +import { PROVIDER_METADATA } from "./metadata"; +import { getRcConfig, type RcConfig } from "@lingo.dev/config"; +import { ProviderKeyMissingError } from "./errors"; + +let dotenvLoaded = false; +function loadDotEnvOnce() { + if (dotenvLoaded) return; + const candidates = [ + path.resolve(process.cwd(), ".env"), + path.resolve(process.cwd(), ".env.local"), + path.resolve(process.cwd(), ".env.development"), + ]; + for (const file of candidates) { + if (fs.existsSync(file)) { + dotenv.config({ path: file }); + } + } + dotenvLoaded = true; +} + +function getByPath(obj: any, keyPath?: string): any { + if (!obj || !keyPath) return undefined; + return keyPath.split(".").reduce((acc, key) => (acc ? acc[key] : undefined), obj); +} + +export interface KeySources { + env?: Record; + rc?: RcConfig; +} + +export function resolveProviderApiKey( + providerId: ProviderId, + options?: { sources?: KeySources; required?: boolean }, +): string | undefined { + const meta = PROVIDER_METADATA[providerId]; + if (!meta) return undefined; + + const sources = options?.sources ?? {}; + + let envVal: string | undefined; + if (sources.env) { + envVal = meta.apiKeyEnvVar ? sources.env[meta.apiKeyEnvVar] : undefined; + } else { + loadDotEnvOnce(); + envVal = meta.apiKeyEnvVar ? process.env[meta.apiKeyEnvVar] : undefined; + } + + const rc = sources.rc ?? getRcConfig(); + const rcVal = getByPath(rc, meta.apiKeyConfigKey); + + const key = envVal || rcVal; + if (!key && options?.required) { + throw new ProviderKeyMissingError( + providerId, + meta.apiKeyEnvVar, + meta.apiKeyConfigKey, + ); + } + return key; +} diff --git a/packages/compiler/src/lib/lcp/api/provider-details.ts b/packages/providers/src/metadata.ts similarity index 50% rename from packages/compiler/src/lib/lcp/api/provider-details.ts rename to packages/providers/src/metadata.ts index b4468720d..5a6394270 100644 --- a/packages/compiler/src/lib/lcp/api/provider-details.ts +++ b/packages/providers/src/metadata.ts @@ -1,15 +1,12 @@ -import { openrouter } from "@openrouter/ai-sdk-provider"; +export interface ProviderMetadata { + name: string; + apiKeyEnvVar?: string; + apiKeyConfigKey?: string; + getKeyLink: string; + docsLink: string; +} -export const providerDetails: Record< - string, - { - name: string; // Display name (e.g., "Groq", "Google") - apiKeyEnvVar?: string; // Environment variable name (e.g., "GROQ_API_KEY") - apiKeyConfigKey?: string; // Config key if applicable (e.g., "llm.groqApiKey") - getKeyLink: string; // Link to get API key - docsLink: string; // Link to API docs for troubleshooting - } -> = { +export const PROVIDER_METADATA = { groq: { name: "Groq", apiKeyEnvVar: "GROQ_API_KEY", @@ -17,6 +14,20 @@ export const providerDetails: Record< getKeyLink: "https://groq.com", docsLink: "https://console.groq.com/docs/errors", }, + openai: { + name: "OpenAI", + apiKeyEnvVar: "OPENAI_API_KEY", + apiKeyConfigKey: "llm.openaiApiKey", + getKeyLink: "https://platform.openai.com", + docsLink: "https://platform.openai.com/docs", + }, + anthropic: { + name: "Anthropic", + apiKeyEnvVar: "ANTHROPIC_API_KEY", + apiKeyConfigKey: "llm.anthropicApiKey", + getKeyLink: "https://console.anthropic.com", + docsLink: "https://docs.anthropic.com", + }, google: { name: "Google", apiKeyEnvVar: "GOOGLE_API_KEY", @@ -33,8 +44,8 @@ export const providerDetails: Record< }, ollama: { name: "Ollama", - apiKeyEnvVar: undefined, // Ollama doesn't require an API key - apiKeyConfigKey: undefined, // Ollama doesn't require an API key + apiKeyEnvVar: undefined, + apiKeyConfigKey: undefined, getKeyLink: "https://ollama.com/download", docsLink: "https://github.com/ollama/ollama/tree/main/docs", }, @@ -45,11 +56,11 @@ export const providerDetails: Record< getKeyLink: "https://console.mistral.ai", docsLink: "https://docs.mistral.ai", }, - "lingo.dev": { - name: "Lingo.dev", - apiKeyEnvVar: "LINGODOTDEV_API_KEY", - apiKeyConfigKey: "auth.apiKey", - getKeyLink: "https://lingo.dev", - docsLink: "https://lingo.dev/docs", - }, -}; +} as const satisfies Record; + +export type ProviderId = keyof typeof PROVIDER_METADATA; + +// Derive supported providers from metadata keys to prevent drift +export const SUPPORTED_PROVIDERS = Object.freeze( + Object.keys(PROVIDER_METADATA), +) as readonly string[] as readonly ProviderId[]; diff --git a/packages/providers/tsconfig.json b/packages/providers/tsconfig.json new file mode 100644 index 000000000..d7b0f86bc --- /dev/null +++ b/packages/providers/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "allowUnreachableCode": true, + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/providers/tsup.config.ts b/packages/providers/tsup.config.ts new file mode 100644 index 000000000..2d13ece73 --- /dev/null +++ b/packages/providers/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + target: "esnext", + entry: ["src/index.ts"], + outDir: "build", + format: ["cjs", "esm"], + dts: true, + cjsInterop: true, + splitting: true, + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), +}); diff --git a/packages/spec/package.json b/packages/spec/package.json index f1138ffac..06e1fe27a 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -25,6 +25,7 @@ "author": "", "license": "Apache-2.0", "dependencies": { + "@lingo.dev/providers": "workspace:*", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.5" }, diff --git a/packages/spec/src/config.ts b/packages/spec/src/config.ts index 34ab0aaa8..80c38d873 100644 --- a/packages/spec/src/config.ts +++ b/packages/spec/src/config.ts @@ -1,4 +1,5 @@ import Z from "zod"; +import { SUPPORTED_PROVIDERS } from "@lingo.dev/providers"; import { localeCodeSchema } from "./locales"; import { bucketTypeSchema } from "./formats"; @@ -485,8 +486,47 @@ export const configV1_10Definition = extendConfigDefinition( }, ); -// exports -export const LATEST_CONFIG_DEFINITION = configV1_10Definition; +// v1.10 -> v1.11 +// Changes: Add "groq" to provider enum +const providerSchemaV1_11 = Z.object({ + id: Z.enum([ + "openai", + "anthropic", + "google", + "ollama", + "openrouter", + "mistral", + "groq", + ]).describe("Identifier of the translation provider service."), + model: Z.string().describe("Model name to use for translations."), + prompt: Z.string().describe( + "Prompt template used when requesting translations.", + ), + baseUrl: Z.string() + .optional() + .describe("Custom base URL for the provider API (optional)."), + settings: modelSettingsSchema, +}).describe("Configuration for the machine-translation provider."); + +export const configV1_11Definition = extendConfigDefinition( + configV1_10Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + provider: providerSchemaV1_11.optional(), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: "1.11", + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: "1.11", + }), + }, +); + +export const LATEST_CONFIG_DEFINITION = configV1_11Definition; export type I18nConfig = Z.infer<(typeof LATEST_CONFIG_DEFINITION)["schema"]>; diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts index 297ea8cb4..5a1a959c4 100644 --- a/packages/spec/tsup.config.ts +++ b/packages/spec/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ dts: true, cjsInterop: true, splitting: true, + external: ["@lingo.dev/providers"], outExtension: (ctx) => ({ js: ctx.format === "cjs" ? ".cjs" : ".mjs", }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8358a3c52..747ce0d0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,18 +341,6 @@ importers: packages/cli: dependencies: - '@ai-sdk/anthropic': - specifier: ^1.2.11 - version: 1.2.11(zod@3.25.76) - '@ai-sdk/google': - specifier: ^1.2.19 - version: 1.2.19(zod@3.25.76) - '@ai-sdk/mistral': - specifier: ^1.2.8 - version: 1.2.8(zod@3.25.76) - '@ai-sdk/openai': - specifier: ^1.3.22 - version: 1.3.22(zod@3.25.76) '@babel/generator': specifier: ^7.27.1 version: 7.27.1 @@ -404,9 +392,6 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.5.0 version: 1.5.0 - '@openrouter/ai-sdk-provider': - specifier: ^0.7.1 - version: 0.7.1(ai@4.3.15(react@18.3.1)(zod@3.25.76))(zod@3.25.76) '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.2.2 @@ -542,9 +527,6 @@ importers: octokit: specifier: ^4.0.2 version: 4.1.2 - ollama-ai-provider: - specifier: ^1.2.0 - version: 1.2.0(zod@3.25.76) open: specifier: ^10.2.0 version: 10.2.0 @@ -651,9 +633,6 @@ importers: '@types/glob': specifier: ^8.1.0 version: 8.1.0 - '@types/ini': - specifier: ^4.1.1 - version: 4.1.1 '@types/is-url': specifier: ^1.2.32 version: 1.2.32 @@ -696,15 +675,6 @@ importers: packages/compiler: dependencies: - '@ai-sdk/google': - specifier: ^1.2.19 - version: 1.2.19(zod@3.25.76) - '@ai-sdk/groq': - specifier: ^1.2.3 - version: 1.2.9(zod@3.25.76) - '@ai-sdk/mistral': - specifier: ^1.2.8 - version: 1.2.8(zod@3.25.76) '@babel/generator': specifier: ^7.26.5 version: 7.27.1 @@ -723,9 +693,9 @@ importers: '@lingo.dev/_spec': specifier: workspace:* version: link:../spec - '@openrouter/ai-sdk-provider': - specifier: ^0.7.1 - version: 0.7.1(ai@4.3.15(react@19.1.0)(zod@3.25.76))(zod@3.25.76) + '@lingo.dev/providers': + specifier: workspace:* + version: link:../providers '@prettier/sync': specifier: ^0.6.1 version: 0.6.1(prettier@3.4.2) @@ -753,9 +723,6 @@ importers: object-hash: specifier: ^3.0.0 version: 3.0.0 - ollama-ai-provider: - specifier: ^1.2.0 - version: 1.2.0(zod@3.25.76) posthog-node: specifier: ^5.5.1 version: 5.5.1 @@ -815,6 +782,55 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@25.0.1)(lightningcss@1.30.1)(terser@5.36.0)(tsx@4.20.3)(yaml@2.7.0) + packages/providers: + dependencies: + '@ai-sdk/anthropic': + specifier: ^1.2.11 + version: 1.2.11(zod@3.25.76) + '@ai-sdk/google': + specifier: ^1.2.19 + version: 1.2.19(zod@3.25.76) + '@ai-sdk/groq': + specifier: ^1.2.3 + version: 1.2.9(zod@3.25.76) + '@ai-sdk/mistral': + specifier: ^1.2.8 + version: 1.2.8(zod@3.25.76) + '@ai-sdk/openai': + specifier: ^1.3.22 + version: 1.3.22(zod@3.25.76) + '@openrouter/ai-sdk-provider': + specifier: ^0.7.1 + version: 0.7.1(ai@4.3.15(react@19.1.0)(zod@3.25.76))(zod@3.25.76) + ai: + specifier: ^4.3.15 + version: 4.3.15(react@19.1.0)(zod@3.25.76) + dotenv: + specifier: ^16.4.7 + version: 16.4.7 + ini: + specifier: ^5.0.0 + version: 5.0.0 + ollama-ai-provider: + specifier: ^1.2.0 + version: 1.2.0(zod@3.25.76) + devDependencies: + '@types/ini': + specifier: ^4.1.1 + version: 4.1.1 + '@types/node': + specifier: ^22.13.5 + version: 22.17.2 + tsup: + specifier: ^8.3.5 + version: 8.3.5(@swc/core@1.13.5)(jiti@2.5.1)(postcss@8.5.4)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.7.0) + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.1.2 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@25.0.1)(lightningcss@1.30.1)(terser@5.36.0)(tsx@4.20.3)(yaml@2.7.0) + packages/react: dependencies: js-cookie: @@ -897,6 +913,9 @@ importers: packages/spec: dependencies: + '@lingo.dev/providers': + specifier: workspace:* + version: link:../providers zod: specifier: ^3.25.76 version: 3.25.76 @@ -13341,13 +13360,6 @@ snapshots: '@octokit/request-error': 6.1.7 '@octokit/webhooks-methods': 5.1.1 - '@openrouter/ai-sdk-provider@0.7.1(ai@4.3.15(react@18.3.1)(zod@3.25.76))(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - ai: 4.3.15(react@18.3.1)(zod@3.25.76) - zod: 3.25.76 - '@openrouter/ai-sdk-provider@0.7.1(ai@4.3.15(react@19.1.0)(zod@3.25.76))(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.1.3