ai-l10n-core 1.4.1 → 1.5.1

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,91 @@ 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.1] - 2026-04-17
9
+
10
+ ### Changed
11
+ - Set default client identifier on `translate()` requests.
12
+ - Standardized the `reason` type used in API error responses to a fixed union of values.
13
+
14
+ ### Fixed
15
+ - API documentation
16
+
17
+ ## [1.5.0] - 2026-04-16
18
+
19
+ ### Added
20
+ - **`ApiResponse<T>` type** — Generic discriminated union used as the return type of all API methods:
21
+ ```typescript
22
+ type ApiResponse<T> =
23
+ | { success: true; data: T }
24
+ | {
25
+ success: false;
26
+ reason:
27
+ | "paymentRequired"
28
+ | "translationError"
29
+ | "requestTooLarge"
30
+ | "badRequest"
31
+ | "unauthorized"
32
+ | "forbidden"
33
+ | "rateLimited"
34
+ | "serverError"
35
+ | "networkError"
36
+ | "noApiKey";
37
+ message: string;
38
+ };
39
+ ```
40
+ - **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).
41
+ - **`BalanceResponse` type** — `{ currentBalance: number }`
42
+ - **Structured responses for all methods** — `getBalance()`, `getLanguages()`, and `predictLanguages()` now return `ApiResponse<T>` and never throw
43
+
44
+ ### Changed
45
+ - **`translate()` return type changed from `TranslationResult | null` to `TranslationResponse`** (breaking change)
46
+ - `TranslationResponse` is now `ApiResponse<TranslationResult> & { currentBalance?: number }`
47
+ - The method always resolves — it never throws
48
+ - On success: `{ success: true, data: TranslationResult, currentBalance?: number }`
49
+ - On error: `{ success: false, reason: string, message: string, currentBalance?: number }`
50
+ - `reason` values: "paymentRequired", "translationError", "requestTooLarge", "badRequest", "unauthorized", "forbidden", "rateLimited", "serverError", "networkError", "noApiKey"
51
+ - **`getLanguages()` now requires `apiKey` as its first parameter** (bug fix — previously no API key was sent)
52
+ - New signature: `getLanguages(apiKey: string, options?: ...): Promise<ApiResponse<SupportedLanguagesResponse>>`
53
+ - Returns structured `ApiResponse` instead of throwing; error reasons: `noApiKey`, `unauthorized`, `badRequest`, `networkError`
54
+ - **`getBalance()` no longer throws** — returns `ApiResponse<BalanceResponse>`; error reasons: `noApiKey`, `unauthorized`, `networkError`
55
+ - **`predictLanguages()` no longer throws** — returns `ApiResponse<Language[]>`; error reason: `networkError`
56
+
57
+ ### Migration
58
+ ```typescript
59
+ // translate() — Before (1.4.x)
60
+ try {
61
+ const result = await service.translate(request, apiKey);
62
+ if (!result) return; // null for 401/402
63
+ console.log(result.translations);
64
+ } catch (e) {
65
+ console.error(e.message); // thrown for 400/413/500
66
+ }
67
+
68
+ // translate() — After (1.5.0)
69
+ const response = await service.translate(request, apiKey);
70
+ if (!response.success) {
71
+ console.error(response.message, '| reason:', response.reason);
72
+ return;
73
+ }
74
+ console.log(response.data.translations);
75
+
76
+ // getBalance() — Before (1.4.x)
77
+ const { currentBalance } = await service.getBalance(apiKey); // threw on error
78
+
79
+ // getBalance() — After (1.5.0)
80
+ const result = await service.getBalance(apiKey);
81
+ if (!result.success) console.error(result.reason);
82
+ else console.log(result.data.currentBalance);
83
+
84
+ // getLanguages() — Before (1.4.x)
85
+ const { languages } = await service.getLanguages(options); // no apiKey, threw on error
86
+
87
+ // getLanguages() — After (1.5.0)
88
+ const result = await service.getLanguages(apiKey, options);
89
+ if (!result.success) console.error(result.reason);
90
+ else console.log(result.data.languages);
91
+ ```
92
+
8
93
  ## [1.4.0] - 2026-04-02
9
94
 
10
95
  ### 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
131
150
 
132
- ##### `predictLanguages(input: string, limit?: number): Promise<Language[]>`
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.
152
+
153
+ **Error reasons:** `noApiKey`, `unauthorized`, `serverError`, `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,90 @@ 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:** , `serverError`, `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`, `serverError`, `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
+ | {
220
+ success: false;
221
+ reason:
222
+ | "paymentRequired"
223
+ | "translationError"
224
+ | "requestTooLarge"
225
+ | "badRequest"
226
+ | "unauthorized"
227
+ | "forbidden"
228
+ | "rateLimited"
229
+ | "serverError"
230
+ | "networkError"
231
+ | "noApiKey";
232
+ message: string;
233
+ };
234
+ ```
235
+
236
+ All methods that contact the API return an `ApiResponse<T>`. Check `success` before accessing `data`.
237
+
238
+ ##### TranslationResponse
239
+
240
+ ```typescript
241
+ type TranslationResponse = ApiResponse<TranslationResult> & { currentBalance?: number };
242
+ ```
243
+
244
+ The union distributes over the intersection, so `currentBalance?` is available on both branches:
245
+ - On `success: true`: `data` holds the `TranslationResult`, `currentBalance` is the remaining balance.
246
+ - On `success: false`: `reason` and `message` describe the error, `currentBalance` is set when `reason` is `"paymentRequired"`.
247
+
248
+ ##### BalanceResponse
249
+
250
+ ```typescript
251
+ interface BalanceResponse {
252
+ /** Current balance of characters available for translation. */
253
+ currentBalance: number;
254
+ }
255
+ ```
256
+
173
257
  ##### TranslationRequest
174
258
 
175
259
  ```typescript
@@ -192,9 +276,6 @@ interface TranslationRequest {
192
276
  /** Translate metadata along with UI strings (e.g., ARB `@key` descriptions) */
193
277
  translateMetadata?: boolean;
194
278
 
195
- /** Return translations as JSON string */
196
- returnTranslationsAsString: boolean;
197
-
198
279
  /** Client identifier */
199
280
  client: string;
200
281
 
@@ -284,9 +365,6 @@ enum FinishReason {
284
365
  /** Some content was filtered due to content policy */
285
366
  contentFilter = "contentFilter",
286
367
 
287
- /** Insufficient character balance */
288
- insufficientBalance = "insufficientBalance",
289
-
290
368
  /** Translation failed with error */
291
369
  error = "error"
292
370
  }
@@ -319,17 +397,48 @@ interface SupportedLanguage {
319
397
 
320
398
  #### Error Handling
321
399
 
322
- The service throws errors for:
323
-
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: ...)"`
327
-
328
- Returns `null` with error logged for:
329
-
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..."`
400
+ All methods always resolve — they never throw. Check `response.success`:
401
+
402
+ ##### `translate()` error reasons
403
+
404
+ | `reason` | Description |
405
+ |----------|-------------|
406
+ | `"paymentRequired"` | Insufficient balance (402); `currentBalance` is set |
407
+ | `"translationError"` | Translation failed; API returned `finishReason: "error"` |
408
+ | `"requestTooLarge"` | Request exceeds 5 MB (413) |
409
+ | `"badRequest"` | Validation error (400); `message` contains details |
410
+ | `"unauthorized"` | API key is invalid (401) |
411
+ | `"forbidden"` | API key lacks required permissions (403) |
412
+ | `"rateLimited"` | Requests are being rate-limited (429) |
413
+ | `"serverError"` | Internal server error (500) |
414
+ | `"networkError"` | Connection or other failure |
415
+ | `"noApiKey"` | API key was not provided |
416
+
417
+ ##### `getBalance()` error reasons
418
+
419
+ | `reason` | Description |
420
+ |----------|-------------|
421
+ | `"noApiKey"` | API key was not provided |
422
+ | `"unauthorized"` | API key is invalid (401) |
423
+ | `"serverError"` | Internal server error (500) |
424
+ | `"networkError"` | Connection or other failure |
425
+
426
+ ##### `getLanguages()` error reasons
427
+
428
+ | `reason` | Description |
429
+ |----------|-------------|
430
+ | `"noApiKey"` | API key was not provided |
431
+ | `"unauthorized"` | API key is invalid (401) |
432
+ | `"badRequest"` | Validation error (400); `message` contains details |
433
+ | `"serverError"` | Internal server error (500) |
434
+ | `"networkError"` | Connection or other failure |
435
+
436
+ ##### `predictLanguages()` error reasons
437
+
438
+ | `reason` | Description |
439
+ |----------|-------------|
440
+ | `"serverError"` | Internal server error (500) |
441
+ | `"networkError"` | Connection or other failure |
333
442
 
334
443
  ---
335
444
 
@@ -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: "paymentRequired" | "translationError" | "requestTooLarge" | "badRequest" | "unauthorized" | "forbidden" | "rateLimited" | "serverError" | "networkError" | "noApiKey";
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,20 +81,21 @@ 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}`);
96
+ if (!request.client) {
97
+ request.client = "ai-l10n-core-npmjs";
98
+ }
69
99
  const response = await fetch(`${constants_1.URLS.API_BASE}/v2/translate`, {
70
100
  method: "POST",
71
101
  headers: {
@@ -74,82 +104,140 @@ class L10nTranslationService {
74
104
  },
75
105
  body: JSON.stringify(request),
76
106
  });
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();
107
+ if (response.ok) {
108
+ const result = (await response.json());
109
+ const currentBalance = result?.remainingBalance;
110
+ // Note: FinishReason.contentFilter and FinishReason.length return partial results with filteredStrings populated, so we treat them as success cases.
111
+ // Only FinishReason.error is treated as an error status.
112
+ if (!result || result.finishReason === FinishReason.error) {
113
+ const message = "Translation failed due to an error.";
114
+ this.logger.showAndLogError(message);
115
+ return {
116
+ success: false,
117
+ reason: "translationError",
118
+ message,
119
+ currentBalance,
120
+ };
83
121
  }
84
- catch {
85
- // Ignore JSON parsing errors
122
+ if (result.finishReason !== FinishReason.stop) {
123
+ this.logger.logWarning(`Translation finished with reason: ${result.finishReason}`);
86
124
  }
87
- this.logger.logWarning(`Translation API error - ${response.status} ${response.statusText}`);
88
- if (errorData) {
89
- this.logger.logWarning(`API error details - ${JSON.stringify(errorData)}`);
125
+ return { success: true, data: result, currentBalance };
126
+ }
127
+ if (response.status === 402) {
128
+ this.logger.logWarning(`Translation error - ${response.status} ${response.statusText}`);
129
+ const errorData = await this.parseErrorBody(response);
130
+ let message = "Not enough characters remaining for this translation. You can try translating a smaller portion of your content or purchase more characters.";
131
+ const requiredBalance = errorData?.data?.requiredBalance;
132
+ const currentBalance = errorData?.data?.currentBalance;
133
+ if (requiredBalance && currentBalance !== undefined) {
134
+ 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
135
  }
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(" ");
136
+ this.logger.showAndLogError(message, !requiredBalance ? errorData : undefined, "", "Visit l10n.dev", constants_1.URLS.PRICING);
137
+ return {
138
+ success: false,
139
+ reason: "paymentRequired",
140
+ message,
141
+ currentBalance,
142
+ };
143
+ }
144
+ else if (response.status === 413) {
145
+ this.logger.logWarning(`Translation error - ${response.status} ${response.statusText}`);
146
+ const errorData = await this.parseErrorBody(response);
147
+ const message = "Request too large. Maximum request size is 5 MB.";
148
+ this.logger.showAndLogError(message, errorData);
149
+ return { success: false, reason: "requestTooLarge", message };
150
+ }
151
+ return this.handleErrorResponse(response, "Translate");
152
+ }
153
+ checkApiKey(apiKey) {
154
+ if (!apiKey) {
155
+ const message = "API Key not set. Please configure your API Key first.";
156
+ this.logger.showAndLogError(message, null, "", "Get API Key", constants_1.URLS.API_KEYS);
157
+ return { success: false, reason: "noApiKey", message };
158
+ }
159
+ return null;
160
+ }
161
+ /**
162
+ * Maps an HTTP error to a structured ApiResponse error.
163
+ * Handles 400 (with validation error extraction), 401, 403, 429, 502, 503, and 500.
164
+ */
165
+ async handleErrorResponse(response, title) {
166
+ this.logger.logWarning(`${title} failed - ${response.status} ${response.statusText}`);
167
+ const errorData = await this.parseErrorBody(response);
168
+ const status = response.status;
169
+ switch (status) {
170
+ case 400: {
171
+ let message = "Invalid request. Please check your input and try again.";
172
+ if (errorData?.errors &&
173
+ typeof errorData.errors === "object" &&
174
+ !Array.isArray(errorData.errors)) {
175
+ const e = errorData.errors;
176
+ message = Object.keys(e)
177
+ .map((k) => {
178
+ const v = e[k];
179
+ if (k) {
180
+ // not empty key
181
+ if (Array.isArray(v)) {
182
+ return `${k}: ${v.join(" ")}`;
183
+ }
184
+ else {
185
+ return `${k}: ${String(v)}`;
186
+ }
104
187
  }
105
- }
106
- errorMessage = validationMessage;
107
- break;
188
+ return Array.isArray(v) ? v.join(" ") : String(v);
189
+ })
190
+ .join("; \r\n");
191
+ this.logger.showAndLogError(message);
108
192
  }
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;
193
+ else {
194
+ this.logger.showAndLogError(message, errorData);
123
195
  }
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.";
196
+ return { success: false, reason: "badRequest", message };
133
197
  }
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}`);
198
+ case 401: {
199
+ const message = "Unauthorized. Please check your API Key.";
200
+ this.logger.showAndLogError(message, errorData, "", "Get API Key", constants_1.URLS.API_KEYS);
201
+ return { success: false, reason: "unauthorized", message: message };
202
+ }
203
+ case 403: {
204
+ const message = "Forbidden. You don't have permission to access this resource.";
205
+ this.logger.showAndLogError(message, errorData);
206
+ return { success: false, reason: "forbidden", message: message };
207
+ }
208
+ case 429: {
209
+ const message = "Too many requests. You're being rate limited. Please try again later.";
210
+ this.logger.showAndLogError(message, errorData);
211
+ return { success: false, reason: "rateLimited", message: message };
141
212
  }
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
213
+ case 502:
214
+ case 503:
215
+ case 500: {
216
+ const message = `An internal server error occurred (Error code: ${errorData?.errorCode ?? "unknown"}). Please try again later.`;
217
+ this.logger.showAndLogError(message, errorData);
218
+ return { success: false, reason: "serverError", message: message };
150
219
  }
220
+ default: {
221
+ const message = `Failed to ${title.toLowerCase()}: ${response.status} ${response.statusText}`;
222
+ this.logger.showAndLogError(message, errorData);
223
+ return { success: false, reason: "networkError", message: message };
224
+ }
225
+ }
226
+ }
227
+ /**
228
+ * Attempts to parse the response body as JSON. If parsing fails, returns the raw text.
229
+ * Logs a warning if JSON parsing fails.
230
+ */
231
+ async parseErrorBody(response) {
232
+ let rawBody;
233
+ try {
234
+ rawBody = await response.text();
235
+ return JSON.parse(rawBody);
236
+ }
237
+ catch {
238
+ this.logger.logWarning("Failed to parse error response");
239
+ return rawBody;
151
240
  }
152
- return result;
153
241
  }
154
242
  }
155
243
  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.1",
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",