Skip to content

Commit 12cd90f

Browse files
committed
feat: refactor validation of api keys
1 parent d40047c commit 12cd90f

4 files changed

Lines changed: 217 additions & 159 deletions

File tree

cmp/compiler/src/translators/lcp/lcp-translator.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import type { DictionarySchema, TranslatableEntry, Translator } from "../api";
44
import { getSystemPrompt } from "./prompt";
55
import { obj2xml, xml2obj } from "./xml2obj";
66
import { shots } from "./shots";
7-
import { getLingoDotDevKey } from "./api-keys";
8-
import { createAiModel, getLocaleModel } from "./model-factory";
7+
import {
8+
createAiModel,
9+
getLocaleModel,
10+
validateAndGetApiKeys,
11+
type ValidatedApiKeys,
12+
} from "./model-factory";
913
import { Logger } from "../../utils/logger";
1014
import { DEFAULT_TIMEOUTS, withTimeout } from "../../utils/timeout";
1115

@@ -22,10 +26,16 @@ export interface LCPTranslatorConfig {
2226
* LCP-based translator using AI models
2327
*/
2428
export class LCPTranslator implements Translator<LCPTranslatorConfig> {
29+
private readonly validatedKeys: ValidatedApiKeys;
30+
2531
constructor(
2632
readonly config: LCPTranslatorConfig,
2733
private logger: Logger,
28-
) {}
34+
) {
35+
this.logger.info("Validating API keys for translation...");
36+
this.validatedKeys = validateAndGetApiKeys(config.models);
37+
this.logger.info("✅ API keys validated successfully");
38+
}
2939

3040
/**
3141
* Translate multiple entries
@@ -127,10 +137,10 @@ export class LCPTranslator implements Translator<LCPTranslatorConfig> {
127137
): Promise<DictionarySchema> {
128138
this.logger.debug(`[TRACE-LCP] translateWithLingoDotDev() called`);
129139

130-
const apiKey = getLingoDotDevKey();
140+
const apiKey = this.validatedKeys["lingo.dev"];
131141
if (!apiKey) {
132142
throw new Error(
133-
"⚠️ Lingo.dev API key not found. Please set LINGODOTDEV_API_KEY environment variable.",
143+
"Internal error: Lingo.dev API key not found after validation. Please restart the service.",
134144
);
135145
}
136146

@@ -197,7 +207,7 @@ export class LCPTranslator implements Translator<LCPTranslatorConfig> {
197207
`Using LLM ("${localeModel.provider}":"${localeModel.name}") to translate from "${this.config.sourceLocale}" to "${targetLocale}"`,
198208
);
199209

200-
const aiModel = createAiModel(localeModel);
210+
const aiModel = createAiModel(localeModel, this.validatedKeys);
201211

202212
try {
203213
const response = await withTimeout(

cmp/compiler/src/translators/lcp/model-factory.ts

Lines changed: 189 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,89 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
88
import { createOllama } from "ollama-ai-provider";
99
import { createMistral } from "@ai-sdk/mistral";
1010
import type { LanguageModel } from "ai";
11-
import {
12-
getGoogleKey,
13-
getGroqKey,
14-
getMistralKey,
15-
getOpenRouterKey,
16-
} from "./api-keys";
17-
18-
type LocaleModel = {
11+
import { getKeyFromEnv } from "./api-keys";
12+
13+
export type LocaleModel = {
1914
provider: string;
2015
name: string;
2116
};
2217

18+
/**
19+
* Pre-validated API keys for all providers
20+
* Keys are fetched and validated once at initialization
21+
*/
22+
export type ValidatedApiKeys = Record<string, string>;
23+
24+
/**
25+
* Provider configuration including env var names and requirements
26+
*/
27+
type ProviderConfig = {
28+
name: string; // Display name (e.g., "Groq", "Google")
29+
apiKeyEnvVar?: string; // Environment variable name (e.g., "GROQ_API_KEY")
30+
apiKeyConfigKey?: string; // Config key if applicable (e.g., "llm.groqApiKey")
31+
getKeyLink: string; // Link to get API key
32+
docsLink: string; // Link to API docs for troubleshooting
33+
};
34+
35+
export const providerDetails: Record<string, ProviderConfig> = {
36+
groq: {
37+
name: "Groq",
38+
apiKeyEnvVar: "GROQ_API_KEY",
39+
apiKeyConfigKey: "llm.groqApiKey",
40+
getKeyLink: "https://groq.com",
41+
docsLink: "https://console.groq.com/docs/errors",
42+
},
43+
google: {
44+
name: "Google",
45+
apiKeyEnvVar: "GOOGLE_API_KEY",
46+
apiKeyConfigKey: "llm.googleApiKey",
47+
getKeyLink: "https://ai.google.dev/",
48+
docsLink: "https://ai.google.dev/gemini-api/docs/troubleshooting",
49+
},
50+
openai: {
51+
name: "OpenAI",
52+
apiKeyEnvVar: "OPENAI_API_KEY",
53+
apiKeyConfigKey: "llm.openaiApiKey",
54+
getKeyLink: "https://platform.openai.com/account/api-keys",
55+
docsLink: "https://platform.openai.com/docs",
56+
},
57+
anthropic: {
58+
name: "Anthropic",
59+
apiKeyEnvVar: "ANTHROPIC_API_KEY",
60+
apiKeyConfigKey: "llm.anthropicApiKey",
61+
getKeyLink: "https://console.anthropic.com/get-api-key",
62+
docsLink: "https://console.anthropic.com/docs",
63+
},
64+
openrouter: {
65+
name: "OpenRouter",
66+
apiKeyEnvVar: "OPENROUTER_API_KEY",
67+
apiKeyConfigKey: "llm.openrouterApiKey",
68+
getKeyLink: "https://openrouter.ai",
69+
docsLink: "https://openrouter.ai/docs",
70+
},
71+
ollama: {
72+
name: "Ollama",
73+
apiKeyEnvVar: undefined, // Ollama doesn't require an API key
74+
apiKeyConfigKey: undefined, // Ollama doesn't require an API key
75+
getKeyLink: "https://ollama.com/download",
76+
docsLink: "https://github.com/ollama/ollama/tree/main/docs",
77+
},
78+
mistral: {
79+
name: "Mistral",
80+
apiKeyEnvVar: "MISTRAL_API_KEY",
81+
apiKeyConfigKey: "llm.mistralApiKey",
82+
getKeyLink: "https://console.mistral.ai",
83+
docsLink: "https://docs.mistral.ai",
84+
},
85+
"lingo.dev": {
86+
name: "Lingo.dev",
87+
apiKeyEnvVar: "LINGODOTDEV_API_KEY",
88+
apiKeyConfigKey: "auth.apiKey",
89+
getKeyLink: "https://lingo.dev",
90+
docsLink: "https://lingo.dev/docs",
91+
},
92+
};
93+
2394
/**
2495
* Get provider and model for a specific locale pair
2596
*/
@@ -68,61 +139,128 @@ export function parseModelString(modelString: string): LocaleModel | undefined {
68139
}
69140

70141
/**
71-
* Create AI model instance from provider and model ID
142+
* Validate and fetch all necessary API keys for the given configuration
143+
* This should be called once at initialization time
72144
*
73-
* @param model Provider name (groq, google, openrouter, ollama, mistral) and model identifier as an object
74-
* @returns LanguageModel instance
75-
* @throws Error if provider is not supported or API key is missing
145+
* @param config Model configuration ("lingo.dev" or locale-pair mapping)
146+
* @returns Validated API keys (provider ID -> API key)
147+
* @throws Error if required keys are missing
76148
*/
77-
export function createAiModel(model: LocaleModel): LanguageModel {
78-
switch (model.provider) {
79-
case "groq": {
80-
const apiKey = getGroqKey();
81-
if (!apiKey) {
82-
throw new Error(
83-
"⚠️ GROQ API key not found. Please set GROQ_API_KEY environment variable.",
84-
);
149+
export function validateAndGetApiKeys(
150+
config: "lingo.dev" | Record<string, string>,
151+
): ValidatedApiKeys {
152+
const keys: ValidatedApiKeys = {};
153+
const missingKeys: Array<{ provider: string; envVar: string }> = [];
154+
155+
// Determine which providers are configured
156+
let providersToValidate: string[];
157+
158+
if (config === "lingo.dev") {
159+
// Only need lingo.dev provider
160+
providersToValidate = ["lingo.dev"];
161+
} else {
162+
// Extract unique providers from model strings
163+
const providerSet = new Set<string>();
164+
Object.values(config).forEach((modelString) => {
165+
const model = parseModelString(modelString);
166+
if (model) {
167+
providerSet.add(model.provider);
85168
}
86-
return createGroq({ apiKey })(model.name);
169+
});
170+
providersToValidate = Array.from(providerSet);
171+
}
172+
173+
// Validate and fetch keys for each provider
174+
for (const provider of providersToValidate) {
175+
const providerConfig = providerDetails[provider];
176+
177+
if (!providerConfig) {
178+
throw new Error(
179+
`⚠️ Unknown provider "${provider}". Supported providers: ${Object.keys(providerDetails).join(", ")}`,
180+
);
87181
}
88182

89-
case "google": {
90-
const apiKey = getGoogleKey();
91-
if (!apiKey) {
92-
throw new Error(
93-
"⚠️ Google API key not found. Please set GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
94-
);
95-
}
96-
return createGoogleGenerativeAI({ apiKey })(model.name);
183+
// Skip providers that don't require keys (like Ollama)
184+
if (!providerConfig.apiKeyEnvVar) {
185+
continue;
97186
}
98187

99-
case "openrouter": {
100-
const apiKey = getOpenRouterKey();
101-
if (!apiKey) {
102-
throw new Error(
103-
"⚠️ OpenRouter API key not found. Please set OPENROUTER_API_KEY environment variable.",
104-
);
105-
}
106-
return createOpenRouter({ apiKey })(model.name);
188+
const key = getKeyFromEnv(providerConfig.apiKeyEnvVar);
189+
if (key) {
190+
keys[provider] = key;
191+
} else {
192+
missingKeys.push({
193+
provider: providerConfig.name,
194+
envVar: providerConfig.apiKeyEnvVar,
195+
});
107196
}
197+
}
108198

109-
case "ollama": {
199+
// If any keys are missing, throw with detailed error
200+
if (missingKeys.length > 0) {
201+
const errorLines = missingKeys.map(
202+
({ provider, envVar }) => ` - ${provider}: ${envVar}`,
203+
);
204+
throw new Error(
205+
`⚠️ Missing API keys for configured providers:\n${errorLines.join("\n")}\n\nPlease set the required environment variables.`,
206+
);
207+
}
208+
209+
return keys;
210+
}
211+
212+
/**
213+
* Create AI model instance from provider and model ID
214+
*
215+
* @param model Provider name (groq, google, openrouter, ollama, mistral) and model identifier
216+
* @param validatedKeys Pre-validated API keys from validateAndFetchApiKeys()
217+
* @returns LanguageModel instance
218+
* @throws Error if provider is not supported or API key is missing
219+
*/
220+
export function createAiModel(
221+
model: LocaleModel,
222+
validatedKeys: ValidatedApiKeys,
223+
): LanguageModel {
224+
const providerConfig = providerDetails[model.provider];
225+
226+
if (!providerConfig) {
227+
throw new Error(
228+
`⚠️ Provider "${model.provider}" is not supported. Supported providers: ${Object.keys(providerDetails).join(", ")}`,
229+
);
230+
}
231+
232+
// Get API key if required
233+
const apiKey = providerConfig.apiKeyEnvVar
234+
? validatedKeys[model.provider]
235+
: undefined;
236+
237+
// Verify key is present for providers that require it
238+
if (providerConfig.apiKeyEnvVar && !apiKey) {
239+
throw new Error(
240+
`⚠️ ${providerConfig.name} API key not found. Please set ${providerConfig.apiKeyEnvVar} environment variable.\n\n` +
241+
`This should not happen if validateAndFetchApiKeys() was called. Please restart the service.`,
242+
);
243+
}
244+
245+
// Create the appropriate model instance
246+
switch (model.provider) {
247+
case "groq":
248+
return createGroq({ apiKey: apiKey! })(model.name);
249+
250+
case "google":
251+
return createGoogleGenerativeAI({ apiKey: apiKey! })(model.name);
252+
253+
case "openrouter":
254+
return createOpenRouter({ apiKey: apiKey! })(model.name);
255+
256+
case "ollama":
110257
return createOllama()(model.name);
111-
}
112258

113-
case "mistral": {
114-
const apiKey = getMistralKey();
115-
if (!apiKey) {
116-
throw new Error(
117-
"⚠️ Mistral API key not found. Please set MISTRAL_API_KEY environment variable.",
118-
);
119-
}
120-
return createMistral({ apiKey })(model.name);
121-
}
259+
case "mistral":
260+
return createMistral({ apiKey: apiKey! })(model.name);
122261

123262
default:
124-
throw new Error(
125-
`⚠️ Provider "${model.provider}" is not supported. Supported providers: groq, google, openrouter, ollama, mistral`,
126-
);
263+
// This should be unreachable due to check above
264+
throw new Error(`⚠️ Provider "${model.provider}" is not implemented`);
127265
}
128266
}

cmp/compiler/src/translators/translation-service.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ export interface TranslationError {
7878
*/
7979
export class TranslationService {
8080
private useCache = true;
81-
private pluralizationProcessed = false;
8281

8382
constructor(
8483
private translator: Translator<any>,
@@ -104,13 +103,8 @@ export class TranslationService {
104103
): Promise<TranslationResult> {
105104
const startTime = performance.now();
106105

107-
// TODO (AleksandrSl 04/12/2025): Think about how to avoid extra processing.
108-
// Process pluralization ONCE for source locale metadata
109-
// This transforms source text from "You have {count} items" to ICU format
110-
if (
111-
!this.pluralizationProcessed &&
112-
this.config.pluralization?.enabled !== false
113-
) {
106+
// TODO (AleksandrSl 05/12/2025): Most likely you don't need pluralization for the pseudo translation. We could move it as a part of the lcp translator
107+
if (this.config.pluralization?.enabled !== false && !this.config.isPseudo) {
114108
this.logger.info(
115109
"Processing pluralization for source locale metadata...",
116110
);
@@ -125,7 +119,6 @@ export class TranslationService {
125119
this.logger.info(
126120
`Pluralization stats: ${pluralStats.pluralized} pluralized, ${pluralStats.rejected} rejected, ${pluralStats.failed} failed`,
127121
);
128-
this.pluralizationProcessed = true;
129122
}
130123

131124
// Skip translation if target is source locale

0 commit comments

Comments
 (0)