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 +62 -0
- package/README.md +115 -25
- package/dist/consoleLogger.d.ts +0 -4
- package/dist/consoleLogger.js +10 -21
- package/dist/translationService.d.ts +42 -6
- package/dist/translationService.js +163 -78
- package/package.json +1 -1
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<
|
|
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<
|
|
128
|
+
**Returns:** `Promise<TranslationResponse>` — Always resolves (never throws). Check `success` field to determine success or failure.
|
|
129
129
|
|
|
130
|
-
**
|
|
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
|
-
|
|
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[]
|
|
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
|
|
187
|
+
**Returns:** `Promise<ApiResponse<SupportedLanguagesResponse>>` — Always resolves (never throws). On success, `data.languages` is an array of `SupportedLanguage` entries.
|
|
152
188
|
|
|
153
|
-
**
|
|
189
|
+
**Error reasons:** `noApiKey`, `unauthorized`, `badRequest`, `networkError`
|
|
154
190
|
|
|
155
191
|
**Example:**
|
|
156
192
|
```typescript
|
|
157
193
|
// Get all supported languages
|
|
158
|
-
const
|
|
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
|
|
202
|
+
const response = await service.getLanguages(apiKey, {
|
|
162
203
|
proficiencyLevels: ['strong', 'high'],
|
|
163
204
|
});
|
|
164
205
|
|
|
165
206
|
// Filter by specific codes
|
|
166
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
418
|
+
##### `predictLanguages()` error reasons
|
|
329
419
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
420
|
+
| `reason` | Description |
|
|
421
|
+
|----------|-------------|
|
|
422
|
+
| `"networkError"` | Connection or other failure |
|
|
333
423
|
|
|
334
424
|
---
|
|
335
425
|
|
package/dist/consoleLogger.d.ts
CHANGED
package/dist/consoleLogger.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
71
|
+
return this.handleErrorResponse(response, "Get languages");
|
|
45
72
|
}
|
|
46
|
-
|
|
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.
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
return
|
|
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 (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
119
|
+
if (result.finishReason !== FinishReason.stop) {
|
|
120
|
+
this.logger.logWarning(`Translation finished with reason: ${result.finishReason}`);
|
|
86
121
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
185
|
+
return Array.isArray(v) ? v.join(" ") : String(v);
|
|
186
|
+
})
|
|
187
|
+
.join("; \r\n");
|
|
188
|
+
this.logger.showAndLogError(message);
|
|
108
189
|
}
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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