ai-l10n-core 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ All notable changes to the core package will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.0] - 2026-04-16
9
+
10
+ ### Added
11
+ - **`ApiResponse<T>` type** — Generic discriminated union used as the return type of all API methods:
12
+ ```typescript
13
+ type ApiResponse<T> =
14
+ | { success: true; data: T }
15
+ | { success: false; reason: string; message: string };
16
+ ```
17
+ - **Balance API** — New `getBalance(apiKey)` method on `L10nTranslationService` retrieves the current character balance from the `GET /v2/balance` endpoint. Returns `ApiResponse<BalanceResponse>` (always resolves, never throws).
18
+ - **`BalanceResponse` type** — `{ currentBalance: number }`
19
+ - **Structured responses for all methods** — `getBalance()`, `getLanguages()`, and `predictLanguages()` now return `ApiResponse<T>` and never throw
20
+
21
+ ### Changed
22
+ - **`translate()` return type changed from `TranslationResult | null` to `TranslationResponse`** (breaking change)
23
+ - `TranslationResponse` is now `ApiResponse<TranslationResult> & { currentBalance?: number }`
24
+ - The method always resolves — it never throws
25
+ - On success: `{ success: true, data: TranslationResult, currentBalance?: number }`
26
+ - On error: `{ success: false, reason: string, message: string, currentBalance?: number }`
27
+ - `reason` values: `"noApiKey"`, `"unauthorized"`, `"paymentRequired"`, `"badRequest"`, `"requestTooLarge"`, `"serverError"`, `"networkError"`, `"translationError"`
28
+ - **`getLanguages()` now requires `apiKey` as its first parameter** (bug fix — previously no API key was sent)
29
+ - New signature: `getLanguages(apiKey: string, options?: ...): Promise<ApiResponse<SupportedLanguagesResponse>>`
30
+ - Returns structured `ApiResponse` instead of throwing; error reasons: `noApiKey`, `unauthorized`, `badRequest`, `networkError`
31
+ - **`getBalance()` no longer throws** — returns `ApiResponse<BalanceResponse>`; error reasons: `noApiKey`, `unauthorized`, `networkError`
32
+ - **`predictLanguages()` no longer throws** — returns `ApiResponse<Language[]>`; error reason: `networkError`
33
+
34
+ ### Migration
35
+ ```typescript
36
+ // translate() — Before (1.4.x)
37
+ try {
38
+ const result = await service.translate(request, apiKey);
39
+ if (!result) return; // null for 401/402
40
+ console.log(result.translations);
41
+ } catch (e) {
42
+ console.error(e.message); // thrown for 400/413/500
43
+ }
44
+
45
+ // translate() — After (1.5.0)
46
+ const response = await service.translate(request, apiKey);
47
+ if (!response.success) {
48
+ console.error(response.message, '| reason:', response.reason);
49
+ return;
50
+ }
51
+ console.log(response.data.translations);
52
+
53
+ // getBalance() — Before (1.4.x)
54
+ const { currentBalance } = await service.getBalance(apiKey); // threw on error
55
+
56
+ // getBalance() — After (1.5.0)
57
+ const result = await service.getBalance(apiKey);
58
+ if (!result.success) console.error(result.reason);
59
+ else console.log(result.data.currentBalance);
60
+
61
+ // getLanguages() — Before (1.4.x)
62
+ const { languages } = await service.getLanguages(options); // no apiKey, threw on error
63
+
64
+ // getLanguages() — After (1.5.0)
65
+ const result = await service.getLanguages(apiKey, options);
66
+ if (!result.success) console.error(result.reason);
67
+ else console.log(result.data.languages);
68
+ ```
69
+
8
70
  ## [1.4.0] - 2026-04-02
9
71
 
10
72
  ### Added
package/README.md CHANGED
@@ -117,7 +117,7 @@ const service = new L10nTranslationService(customLogger);
117
117
 
118
118
  #### Methods
119
119
 
120
- ##### `translate(request: TranslationRequest, apiKey: string): Promise<TranslationResult | null>`
120
+ ##### `translate(request: TranslationRequest, apiKey: string): Promise<TranslationResponse>`
121
121
 
122
122
  Translates localization content using the l10n.dev API.
123
123
 
@@ -125,11 +125,44 @@ Translates localization content using the l10n.dev API.
125
125
  - `request: TranslationRequest` — Translation request configuration
126
126
  - `apiKey: string` — API key for authentication
127
127
 
128
- **Returns:** `Promise<TranslationResult | null>` — Translation result, or `null` for 401/402 responses
128
+ **Returns:** `Promise<TranslationResponse>` — Always resolves (never throws). Check `success` field to determine success or failure.
129
129
 
130
- **Throws:** Error for 400, 413, 500, and other failure conditions
130
+ **Example:**
131
+ ```typescript
132
+ const response = await service.translate(request, apiKey);
133
+ if (!response.success) {
134
+ console.error(response.message, 'reason:', response.reason);
135
+ if (response.reason === 'paymentRequired') {
136
+ console.log('Current balance:', response.currentBalance);
137
+ }
138
+ } else {
139
+ console.log('Translated:', response.data.translations);
140
+ console.log('Balance remaining:', response.currentBalance);
141
+ }
142
+ ```
143
+
144
+ ##### `getBalance(apiKey: string): Promise<ApiResponse<BalanceResponse>>`
145
+
146
+ Retrieves the current character balance available for translation.
147
+
148
+ **Parameters:**
149
+ - `apiKey: string` — API key for authentication
150
+
151
+ **Returns:** `Promise<ApiResponse<BalanceResponse>>` — Always resolves (never throws). Check `success` to determine success or failure. On success, `data.currentBalance` holds the number of characters available.
131
152
 
132
- ##### `predictLanguages(input: string, limit?: number): Promise<Language[]>`
153
+ **Error reasons:** `noApiKey`, `unauthorized`, `networkError`
154
+
155
+ **Example:**
156
+ ```typescript
157
+ const response = await service.getBalance(apiKey);
158
+ if (!response.success) {
159
+ console.error(response.message, 'reason:', response.reason);
160
+ } else {
161
+ console.log(`Available: ${response.data.currentBalance.toLocaleString()} characters`);
162
+ }
163
+ ```
164
+
165
+ ##### `predictLanguages(input: string, limit?: number): Promise<ApiResponse<Language[]>>`
133
166
 
134
167
  Predicts possible language codes from a text input (language name in English or native language, region, or script).
135
168
 
@@ -137,39 +170,76 @@ Predicts possible language codes from a text input (language name in English or
137
170
  - `input: string` — Text to analyze
138
171
  - `limit?: number` — Maximum number of predictions (default: 10)
139
172
 
140
- **Returns:** `Promise<Language[]>`Array of predicted languages with codes and names
173
+ **Returns:** `Promise<ApiResponse<Language[]>>`Always resolves (never throws). On success, `data` is an array of predicted languages with codes and names.
174
+
175
+ **Error reasons:** `networkError`
141
176
 
142
- ##### `getLanguages(options?: { codes?: string[]; proficiencyLevels?: LanguageProficiencyLevel[] }): Promise<SupportedLanguagesResponse>`
177
+ ##### `getLanguages(apiKey: string, options?: { codes?: string[]; proficiencyLevels?: LanguageProficiencyLevel[] }): Promise<ApiResponse<SupportedLanguagesResponse>>`
143
178
 
144
179
  Retrieves a list of supported languages, optionally filtered by language codes or proficiency levels.
145
180
 
146
181
  **Parameters:**
182
+ - `apiKey: string` — API key for authentication
147
183
  - `options?` — Optional filter object
148
184
  - `codes?: string[]` — Filter by specific language codes (e.g., `["en", "es", "fr"]`)
149
185
  - `proficiencyLevels?: LanguageProficiencyLevel[]` — Filter by proficiency level: `"strong"`, `"high"`, `"moderate"`, or `"limited"`
150
186
 
151
- **Returns:** `Promise<SupportedLanguagesResponse>`Object containing a `languages` array of `SupportedLanguage` entries
187
+ **Returns:** `Promise<ApiResponse<SupportedLanguagesResponse>>`Always resolves (never throws). On success, `data.languages` is an array of `SupportedLanguage` entries.
152
188
 
153
- **Throws:** Error if the API request fails (non-2xx response)
189
+ **Error reasons:** `noApiKey`, `unauthorized`, `badRequest`, `networkError`
154
190
 
155
191
  **Example:**
156
192
  ```typescript
157
193
  // Get all supported languages
158
- const { languages } = await service.getLanguages();
194
+ const response = await service.getLanguages(apiKey);
195
+ if (!response.success) {
196
+ console.error(response.message, 'reason:', response.reason);
197
+ } else {
198
+ const { languages } = response.data;
199
+ }
159
200
 
160
201
  // Filter by proficiency level
161
- const { languages } = await service.getLanguages({
202
+ const response = await service.getLanguages(apiKey, {
162
203
  proficiencyLevels: ['strong', 'high'],
163
204
  });
164
205
 
165
206
  // Filter by specific codes
166
- const { languages } = await service.getLanguages({
207
+ const response = await service.getLanguages(apiKey, {
167
208
  codes: ['en', 'es', 'fr'],
168
209
  });
169
210
  ```
170
211
 
171
212
  #### Types
172
213
 
214
+ ##### ApiResponse\<T\>
215
+
216
+ ```typescript
217
+ type ApiResponse<T> =
218
+ | { success: true; data: T }
219
+ | { success: false; reason: string; message: string };
220
+ ```
221
+
222
+ All methods that contact the API return an `ApiResponse<T>`. Check `success` before accessing `data`.
223
+
224
+ ##### TranslationResponse
225
+
226
+ ```typescript
227
+ type TranslationResponse = ApiResponse<TranslationResult> & { currentBalance?: number };
228
+ ```
229
+
230
+ The union distributes over the intersection, so `currentBalance?` is available on both branches:
231
+ - On `success: true`: `data` holds the `TranslationResult`, `currentBalance` is the remaining balance.
232
+ - On `success: false`: `reason` and `message` describe the error, `currentBalance` is set when `reason` is `"paymentRequired"`.
233
+
234
+ ##### BalanceResponse
235
+
236
+ ```typescript
237
+ interface BalanceResponse {
238
+ /** Current balance of characters available for translation. */
239
+ currentBalance: number;
240
+ }
241
+ ```
242
+
173
243
  ##### TranslationRequest
174
244
 
175
245
  ```typescript
@@ -192,9 +262,6 @@ interface TranslationRequest {
192
262
  /** Translate metadata along with UI strings (e.g., ARB `@key` descriptions) */
193
263
  translateMetadata?: boolean;
194
264
 
195
- /** Return translations as JSON string */
196
- returnTranslationsAsString: boolean;
197
-
198
265
  /** Client identifier */
199
266
  client: string;
200
267
 
@@ -284,9 +351,6 @@ enum FinishReason {
284
351
  /** Some content was filtered due to content policy */
285
352
  contentFilter = "contentFilter",
286
353
 
287
- /** Insufficient character balance */
288
- insufficientBalance = "insufficientBalance",
289
-
290
354
  /** Translation failed with error */
291
355
  error = "error"
292
356
  }
@@ -319,17 +383,43 @@ interface SupportedLanguage {
319
383
 
320
384
  #### Error Handling
321
385
 
322
- The service throws errors for:
386
+ All methods always resolve — they never throw. Check `response.success`:
387
+
388
+ ##### `translate()` error reasons
389
+
390
+ | `reason` | Description |
391
+ |----------|-------------|
392
+ | `"noApiKey"` | API key was not provided |
393
+ | `"unauthorized"` | API key is invalid (401) |
394
+ | `"paymentRequired"` | Insufficient balance (402); `currentBalance` is set |
395
+ | `"badRequest"` | Validation error (400); `message` contains details |
396
+ | `"requestTooLarge"` | Request exceeds 5 MB (413) |
397
+ | `"serverError"` | Internal server error (500) |
398
+ | `"networkError"` | Connection or other failure |
399
+ | `"translationError"` | API returned `finishReason: "error"` |
400
+
401
+ ##### `getBalance()` error reasons
402
+
403
+ | `reason` | Description |
404
+ |----------|-------------|
405
+ | `"noApiKey"` | API key was not provided |
406
+ | `"unauthorized"` | API key is invalid (401) |
407
+ | `"networkError"` | Connection or other failure |
408
+
409
+ ##### `getLanguages()` error reasons
323
410
 
324
- - **400 Bad Request** — Validation error with details
325
- - **413 Request Too Large** — `"Request too large. Maximum request size is 5 MB."`
326
- - **500 Server Error** — `"An internal server error occurred (Error code: ...)"`
411
+ | `reason` | Description |
412
+ |----------|-------------|
413
+ | `"noApiKey"` | API key was not provided |
414
+ | `"unauthorized"` | API key is invalid (401) |
415
+ | `"badRequest"` | Validation error (400); `message` contains details |
416
+ | `"networkError"` | Connection or other failure |
327
417
 
328
- Returns `null` with error logged for:
418
+ ##### `predictLanguages()` error reasons
329
419
 
330
- - **Missing API Key** — `"API Key not set. Please configure your API Key first."`
331
- - **401 Unauthorized** — `"Unauthorized. Please check your API Key."`
332
- - **402 Payment Required** — `"Not enough characters remaining for this translation..."`
420
+ | `reason` | Description |
421
+ |----------|-------------|
422
+ | `"networkError"` | Connection or other failure |
333
423
 
334
424
  ---
335
425
 
@@ -19,8 +19,4 @@ export declare class ConsoleLogger implements ILogger {
19
19
  * Log error message
20
20
  */
21
21
  logError(message: string, error?: unknown): void;
22
- /**
23
- * Log error details to console
24
- */
25
- private logErrorDetails;
26
22
  }
@@ -9,12 +9,11 @@ class ConsoleLogger {
9
9
  * Show and log error message
10
10
  */
11
11
  showAndLogError(message, error, context, linkBtnText, url) {
12
- console.error(`❌ ${message}`);
13
- if (context) {
14
- console.error(`Context: ${context}`);
15
- }
16
12
  if (error) {
17
- this.logErrorDetails(error, console.error);
13
+ console.error(`❌ ${message}`, context ?? "", error);
14
+ }
15
+ else {
16
+ console.error(`❌ ${message}`, context ?? "");
18
17
  }
19
18
  if (linkBtnText && url) {
20
19
  console.error(`${linkBtnText}: ${url}`);
@@ -30,32 +29,22 @@ class ConsoleLogger {
30
29
  * Log warning message
31
30
  */
32
31
  logWarning(message, error) {
33
- console.warn(`⚠️ ${message}`);
34
32
  if (error) {
35
- this.logErrorDetails(error, console.warn);
33
+ console.warn(`⚠️ ${message}`, error);
34
+ }
35
+ else {
36
+ console.warn(`⚠️ ${message}`);
36
37
  }
37
38
  }
38
39
  /**
39
40
  * Log error message
40
41
  */
41
42
  logError(message, error) {
42
- console.error(`❌ ${message}`);
43
43
  if (error) {
44
- this.logErrorDetails(error, console.error);
45
- }
46
- }
47
- /**
48
- * Log error details to console
49
- */
50
- logErrorDetails(error, logFn) {
51
- if (error instanceof Error) {
52
- logFn(`Error: ${error.message}`);
53
- if (error.stack) {
54
- logFn(error.stack);
55
- }
44
+ console.error(`❌ ${message}`, error);
56
45
  }
57
46
  else {
58
- logFn(`Error: ${String(error)}`);
47
+ console.error(`❌ ${message}`);
59
48
  }
60
49
  }
61
50
  }
@@ -16,7 +16,6 @@ export interface TranslationRequest {
16
16
  useShortening?: boolean;
17
17
  generatePluralForms?: boolean;
18
18
  translateMetadata?: boolean;
19
- returnTranslationsAsString: boolean;
20
19
  client: string;
21
20
  translateOnlyNewStrings?: boolean;
22
21
  targetStrings?: string;
@@ -50,7 +49,6 @@ export declare enum FinishReason {
50
49
  stop = "stop",
51
50
  length = "length",
52
51
  contentFilter = "contentFilter",
53
- insufficientBalance = "insufficientBalance",
54
52
  error = "error"
55
53
  }
56
54
  export interface Language {
@@ -82,13 +80,51 @@ export interface SupportedLanguage {
82
80
  export interface SupportedLanguagesResponse {
83
81
  languages: SupportedLanguage[];
84
82
  }
83
+ export interface BalanceResponse {
84
+ /** Current balance of characters available for translation. */
85
+ currentBalance: number;
86
+ }
87
+ /**
88
+ * Discriminated union returned by all public service methods.
89
+ * Check `success` to narrow to the data or error branch.
90
+ */
91
+ export type ApiResponse<T> = {
92
+ success: true;
93
+ data: T;
94
+ } | {
95
+ success: false;
96
+ reason: string;
97
+ message: string;
98
+ };
99
+ /**
100
+ * Response from translate(). Extends ApiResponse<TranslationResult> with an
101
+ * optional currentBalance field present on both the success and error branches.
102
+ *
103
+ * On success: data contains the TranslationResult; currentBalance is the remaining balance.
104
+ * On error: reason and message describe the failure; currentBalance is set on 402 errors.
105
+ */
106
+ export type TranslationResponse = ApiResponse<TranslationResult> & {
107
+ currentBalance?: number;
108
+ };
85
109
  export declare class L10nTranslationService {
86
110
  private readonly logger;
87
111
  constructor(logger?: ILogger);
88
- getLanguages(options?: {
112
+ getBalance(apiKey: string): Promise<ApiResponse<BalanceResponse>>;
113
+ getLanguages(apiKey: string, options?: {
89
114
  codes?: string[];
90
115
  proficiencyLevels?: LanguageProficiencyLevel[];
91
- }): Promise<SupportedLanguagesResponse>;
92
- predictLanguages(input: string, limit?: number): Promise<Language[]>;
93
- translate(request: TranslationRequest, apiKey: string): Promise<TranslationResult | null>;
116
+ }): Promise<ApiResponse<SupportedLanguagesResponse>>;
117
+ predictLanguages(input: string, limit?: number): Promise<ApiResponse<Language[]>>;
118
+ translate(request: TranslationRequest, apiKey: string): Promise<TranslationResponse>;
119
+ private checkApiKey;
120
+ /**
121
+ * Maps an HTTP error to a structured ApiResponse error.
122
+ * Handles 400 (with validation error extraction), 401, 403, 429, 502, 503, and 500.
123
+ */
124
+ private handleErrorResponse;
125
+ /**
126
+ * Attempts to parse the response body as JSON. If parsing fails, returns the raw text.
127
+ * Logs a warning if JSON parsing fails.
128
+ */
129
+ private parseErrorBody;
94
130
  }
@@ -19,7 +19,6 @@ var FinishReason;
19
19
  FinishReason["stop"] = "stop";
20
20
  FinishReason["length"] = "length";
21
21
  FinishReason["contentFilter"] = "contentFilter";
22
- FinishReason["insufficientBalance"] = "insufficientBalance";
23
22
  FinishReason["error"] = "error";
24
23
  })(FinishReason || (exports.FinishReason = FinishReason = {}));
25
24
  // ── Translation Service ─────────────────────────────────────────────────────
@@ -27,7 +26,28 @@ class L10nTranslationService {
27
26
  constructor(logger = new consoleLogger_1.ConsoleLogger()) {
28
27
  this.logger = logger;
29
28
  }
30
- async getLanguages(options) {
29
+ async getBalance(apiKey) {
30
+ const apiKeyError = this.checkApiKey(apiKey);
31
+ if (apiKeyError) {
32
+ return apiKeyError;
33
+ }
34
+ this.logger.logInfo("Fetching current balance");
35
+ const response = await fetch(`${constants_1.URLS.API_BASE}/v2/balance`, {
36
+ headers: {
37
+ "X-API-Key": apiKey,
38
+ },
39
+ });
40
+ if (!response.ok) {
41
+ return this.handleErrorResponse(response, "Get balance");
42
+ }
43
+ const result = (await response.json());
44
+ return { success: true, data: result };
45
+ }
46
+ async getLanguages(apiKey, options) {
47
+ const apiKeyError = this.checkApiKey(apiKey);
48
+ if (apiKeyError) {
49
+ return apiKeyError;
50
+ }
31
51
  const url = new URL(`${constants_1.URLS.API_BASE}/v2/languages`);
32
52
  if (options?.codes) {
33
53
  for (const c of options.codes) {
@@ -39,11 +59,20 @@ class L10nTranslationService {
39
59
  url.searchParams.append("p", p);
40
60
  }
41
61
  }
42
- const response = await fetch(url.toString());
62
+ this.logger.logInfo(`Fetching supported languages with filters - codes: ${options?.codes ? options.codes.join(",") : "none"}, proficiency levels: ${options?.proficiencyLevels
63
+ ? options.proficiencyLevels.join(",")
64
+ : "none"}`);
65
+ const response = await fetch(url.toString(), {
66
+ headers: {
67
+ "X-API-Key": apiKey,
68
+ },
69
+ });
43
70
  if (!response.ok) {
44
- throw new Error(`Failed to get languages: ${response.status} ${response.statusText}`);
71
+ return this.handleErrorResponse(response, "Get languages");
45
72
  }
46
- return (await response.json());
73
+ const result = (await response.json());
74
+ this.logger.logInfo(`Successfully fetched ${result.languages.length} languages`);
75
+ return { success: true, data: result };
47
76
  }
48
77
  async predictLanguages(input, limit = 10) {
49
78
  const url = new URL(`${constants_1.URLS.API_BASE}/v2/languages/predict`);
@@ -52,18 +81,16 @@ class L10nTranslationService {
52
81
  this.logger.logInfo(`Predicting languages for input (${input.length} characters)`);
53
82
  const response = await fetch(url.toString());
54
83
  if (!response.ok) {
55
- this.logger.logWarning(`Language prediction failed - ${response.status} ${response.statusText}`);
56
- const error = new Error(`Failed to predict languages: ${response.statusText}`);
57
- throw error;
84
+ return this.handleErrorResponse(response, "Predict languages");
58
85
  }
59
86
  const result = (await response.json());
60
87
  this.logger.logInfo(`Successfully predicted ${result.languages.length} languages`);
61
- return result.languages;
88
+ return { success: true, data: result.languages };
62
89
  }
63
90
  async translate(request, apiKey) {
64
- if (!apiKey) {
65
- this.logger.showAndLogError("API Key not set. Please configure your API Key first.", null, "", "Get API Key", constants_1.URLS.API_KEYS);
66
- return null;
91
+ const apiKeyError = this.checkApiKey(apiKey);
92
+ if (apiKeyError) {
93
+ return apiKeyError;
67
94
  }
68
95
  this.logger.logInfo(`Starting translation to ${request.targetLanguageCode}`);
69
96
  const response = await fetch(`${constants_1.URLS.API_BASE}/v2/translate`, {
@@ -74,82 +101,140 @@ class L10nTranslationService {
74
101
  },
75
102
  body: JSON.stringify(request),
76
103
  });
77
- if (!response.ok) {
78
- let errorMessage;
79
- let errorData = null;
80
- // Try to parse error response body
81
- try {
82
- errorData = await response.json();
104
+ if (response.ok) {
105
+ const result = (await response.json());
106
+ const currentBalance = result?.remainingBalance;
107
+ // Note: FinishReason.contentFilter and FinishReason.length return partial results with filteredStrings populated, so we treat them as success cases.
108
+ // Only FinishReason.error is treated as an error status.
109
+ if (!result || result.finishReason === FinishReason.error) {
110
+ const message = "Translation failed due to an error.";
111
+ this.logger.showAndLogError(message);
112
+ return {
113
+ success: false,
114
+ reason: "translationError",
115
+ message,
116
+ currentBalance,
117
+ };
83
118
  }
84
- catch {
85
- // Ignore JSON parsing errors
119
+ if (result.finishReason !== FinishReason.stop) {
120
+ this.logger.logWarning(`Translation finished with reason: ${result.finishReason}`);
86
121
  }
87
- this.logger.logWarning(`Translation API error - ${response.status} ${response.statusText}`);
88
- if (errorData) {
89
- this.logger.logWarning(`API error details - ${JSON.stringify(errorData)}`);
122
+ return { success: true, data: result, currentBalance };
123
+ }
124
+ if (response.status === 402) {
125
+ this.logger.logWarning(`Translation error - ${response.status} ${response.statusText}`);
126
+ const errorData = await this.parseErrorBody(response);
127
+ let message = "Not enough characters remaining for this translation. You can try translating a smaller portion of your content or purchase more characters.";
128
+ const requiredBalance = errorData?.data?.requiredBalance;
129
+ const currentBalance = errorData?.data?.currentBalance;
130
+ if (requiredBalance && currentBalance !== undefined) {
131
+ message = `This translation requires ${requiredBalance.toLocaleString()} characters, but you only have ${currentBalance.toLocaleString()} characters available. You can try translating a smaller portion of your content or purchase more characters.`;
90
132
  }
91
- switch (response.status) {
92
- case 400: {
93
- // Try to extract validation errors from the error response
94
- let validationMessage = "Invalid request. Please check your input and try again.";
95
- if (errorData && errorData.errors) {
96
- const errorDetails = errorData.errors;
97
- if (Array.isArray(errorDetails)) {
98
- validationMessage = errorDetails.join(" ");
99
- }
100
- else if (typeof errorDetails === "object") {
101
- validationMessage = Object.values(errorDetails)
102
- .map((v) => (Array.isArray(v) ? v.join(" ") : v))
103
- .join(" ");
133
+ this.logger.showAndLogError(message, !requiredBalance ? errorData : undefined, "", "Visit l10n.dev", constants_1.URLS.PRICING);
134
+ return {
135
+ success: false,
136
+ reason: "paymentRequired",
137
+ message,
138
+ currentBalance,
139
+ };
140
+ }
141
+ else if (response.status === 413) {
142
+ this.logger.logWarning(`Translation error - ${response.status} ${response.statusText}`);
143
+ const errorData = await this.parseErrorBody(response);
144
+ const message = "Request too large. Maximum request size is 5 MB.";
145
+ this.logger.showAndLogError(message, errorData);
146
+ return { success: false, reason: "requestTooLarge", message };
147
+ }
148
+ return this.handleErrorResponse(response, "Translate");
149
+ }
150
+ checkApiKey(apiKey) {
151
+ if (!apiKey) {
152
+ const message = "API Key not set. Please configure your API Key first.";
153
+ this.logger.showAndLogError(message, null, "", "Get API Key", constants_1.URLS.API_KEYS);
154
+ return { success: false, reason: "noApiKey", message };
155
+ }
156
+ return null;
157
+ }
158
+ /**
159
+ * Maps an HTTP error to a structured ApiResponse error.
160
+ * Handles 400 (with validation error extraction), 401, 403, 429, 502, 503, and 500.
161
+ */
162
+ async handleErrorResponse(response, title) {
163
+ this.logger.logWarning(`${title} failed - ${response.status} ${response.statusText}`);
164
+ const errorData = await this.parseErrorBody(response);
165
+ const status = response.status;
166
+ switch (status) {
167
+ case 400: {
168
+ let message = "Invalid request. Please check your input and try again.";
169
+ if (errorData?.errors &&
170
+ typeof errorData.errors === "object" &&
171
+ !Array.isArray(errorData.errors)) {
172
+ const e = errorData.errors;
173
+ message = Object.keys(e)
174
+ .map((k) => {
175
+ const v = e[k];
176
+ if (k) {
177
+ // not empty key
178
+ if (Array.isArray(v)) {
179
+ return `${k}: ${v.join(" ")}`;
180
+ }
181
+ else {
182
+ return `${k}: ${String(v)}`;
183
+ }
104
184
  }
105
- }
106
- errorMessage = validationMessage;
107
- break;
185
+ return Array.isArray(v) ? v.join(" ") : String(v);
186
+ })
187
+ .join("; \r\n");
188
+ this.logger.showAndLogError(message);
108
189
  }
109
- case 401:
110
- errorMessage = "Unauthorized. Please check your API Key.";
111
- this.logger.showAndLogError(errorMessage, errorData, "", "Get API Key", constants_1.URLS.API_KEYS);
112
- return null;
113
- case 402: {
114
- // Try to extract required characters from the error response
115
- let message = "Not enough characters remaining for this translation. You can try translating a smaller portion of your file or purchase more characters.";
116
- const requiredBalance = errorData?.data?.requiredBalance;
117
- if (requiredBalance) {
118
- const currentBalance = errorData?.data?.currentBalance ?? 0;
119
- message = `This translation requires ${requiredBalance.toLocaleString()} characters, but you only have ${currentBalance.toLocaleString()} characters available. You can try translating a smaller portion of your file or purchase more characters.`;
120
- }
121
- this.logger.showAndLogError(message, errorData, "", "Visit l10n.dev", constants_1.URLS.PRICING);
122
- return null;
190
+ else {
191
+ this.logger.showAndLogError(message, errorData);
123
192
  }
124
- case 413:
125
- errorMessage = "Request too large. Maximum request size is 5 MB.";
126
- break;
127
- case 500:
128
- errorMessage = `An internal server error occurred (Error code: ${errorData?.errorCode || "unknown"}). Please try again later.`;
129
- break;
130
- default:
131
- errorMessage =
132
- "Failed to translate. Please check your connection and try again.";
193
+ return { success: false, reason: "badRequest", message };
133
194
  }
134
- throw new Error(errorMessage);
135
- }
136
- const result = (await response.json());
137
- // Handle finish reasons
138
- if (result.finishReason) {
139
- if (result.finishReason !== FinishReason.stop) {
140
- this.logger.logWarning(`Translation finished with reason: ${result.finishReason}`);
195
+ case 401: {
196
+ const message = "Unauthorized. Please check your API Key.";
197
+ this.logger.showAndLogError(message, errorData, "", "Get API Key", constants_1.URLS.API_KEYS);
198
+ return { success: false, reason: "unauthorized", message: message };
199
+ }
200
+ case 403: {
201
+ const message = "Forbidden. You don't have permission to access this resource.";
202
+ this.logger.showAndLogError(message, errorData);
203
+ return { success: false, reason: "forbidden", message: message };
204
+ }
205
+ case 429: {
206
+ const message = "Too many requests. You're being rate limited. Please try again later.";
207
+ this.logger.showAndLogError(message, errorData);
208
+ return { success: false, reason: "rateLimited", message: message };
141
209
  }
142
- switch (result.finishReason) {
143
- case FinishReason.insufficientBalance:
144
- const message = `Not enough characters remaining for this translation. You can try translating a smaller portion of your file or purchase more characters.`;
145
- this.logger.showAndLogError(message, undefined, "", "Visit l10n.dev", constants_1.URLS.PRICING);
146
- return result;
147
- case FinishReason.error:
148
- throw new Error("Translation failed due to an error.");
149
- // Note: FinishReason.contentFilter and FinishReason.length return partial results with filteredStrings
210
+ case 502:
211
+ case 503:
212
+ case 500: {
213
+ const message = `An internal server error occurred (Error code: ${errorData?.errorCode ?? "unknown"}). Please try again later.`;
214
+ this.logger.showAndLogError(message, errorData);
215
+ return { success: false, reason: "serverError", message: message };
150
216
  }
217
+ default: {
218
+ const message = `Failed to ${title.toLowerCase()}: ${response.status} ${response.statusText}`;
219
+ this.logger.showAndLogError(message, errorData);
220
+ return { success: false, reason: "networkError", message: message };
221
+ }
222
+ }
223
+ }
224
+ /**
225
+ * Attempts to parse the response body as JSON. If parsing fails, returns the raw text.
226
+ * Logs a warning if JSON parsing fails.
227
+ */
228
+ async parseErrorBody(response) {
229
+ let rawBody;
230
+ try {
231
+ rawBody = await response.text();
232
+ return JSON.parse(rawBody);
233
+ }
234
+ catch {
235
+ this.logger.logWarning("Failed to parse error response");
236
+ return rawBody;
151
237
  }
152
- return result;
153
238
  }
154
239
  }
155
240
  exports.L10nTranslationService = L10nTranslationService;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-l10n-core",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Platform-independent core for AI-powered localization — translation API client, language utilities, and shared types",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",