Skip to content

Commit 9c77843

Browse files
committed
feat: new providers, supported languages
1 parent dbc240b commit 9c77843

File tree

14 files changed

+525
-25
lines changed

14 files changed

+525
-25
lines changed

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
DEEPLX_CLOUDFLARE_URL=""
2-
DEEPLX_VERCEL_URL=""
2+
DEEPLX_VERCEL_URL=""
3+
PORT=3220

README.md

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# 🌍 Polyglot - Scalable Translation Service
22

3-
A fast, reliable, and modular translation API that supports multiple translation providers with automatic fallback mechanisms. It uses a mixture of free undocumented APIs as well as official ones requiring API keys. It's been designed to be scalable to meet FxEmbed's needs, intended to run across many web hosting providers behind load balancing to have the highest rate limits possible across different translation services.
3+
A fast, reliable, and modular translation API designed to meet the scale required by FxEmbed.
44

5-
## ✨ Features
5+
## 🔄 Native Multi-Provider Architecture
66

7-
- 🔄 **Multi-Provider Architecture**: Google Translate, DeepLX, Bing Translate so far, more to come
7+
We support both scraping free translations from popular services like Google Translate, Bing Translate, and DeepL, as well as paid APIs such as Azure, DeepL API
8+
- 🎯 **Dynamic Selection**: Chooses between providers based on target language and availability
89
- ⚖️ **Load Balancing and Rate Limit Leveling**: Distributes requests across providers
9-
- 🛡️ **Automatic Fallback**: If one provider fails, automatically tries others
10+
- 🛡️ **Automatic Failover**: If one provider fails, automatically tries others
11+
- 🐍 **Designed to Scale**: Use higher rate limits for free services by scaling across servers and network providers
1012

1113
## 🚀 Quick Start
1214

@@ -30,6 +32,32 @@ bun run index.ts
3032

3133
The API will be available at `http://localhost:3000`
3234

35+
### Optional: Configure Paid APIs
36+
37+
Relying on free services alone is not ideal since requests can be throttled or blocked (DeepL in particular is very aggressive at this). So we support a variety of paid translation providers, which luckily have free tiers:
38+
Azure AI Translator - 2M characters free per month
39+
DeepL - 500K characters free per month
40+
AWS - 2M characters free per month, 12 months only
41+
42+
```
43+
# Azure AI Translator
44+
AZURE_TRANSLATOR_KEY="your_azure_key"
45+
AZURE_TRANSLATOR_REGION="eastus"
46+
47+
# DeepL Official API
48+
DEEPL_API_KEY="your_deepl_key"
49+
50+
# DeepLX Cloudflare Worker (if deployed)
51+
DEEPLX_CLOUDFLARE_URL="https://deeplx.example.workers.dev"
52+
53+
# AWS Translate
54+
AWS_ACCESS_KEY_ID="your_access_key_id"
55+
AWS_SECRET_ACCESS_KEY="your_access_key"
56+
AWS_REGION="your_region" # optional, defaults to us-east-1
57+
```
58+
59+
**Note**: Free providers (Google Translate, Bing, DeepLX) work without configuration. Paid APIs are only used if environment variables are provided.
60+
3361
## 📖 API Usage
3462

3563
### Translate Text
@@ -59,12 +87,12 @@ The API will be available at `http://localhost:3000`
5987

6088
```bash
6189
# Basic translation, auto detect language
62-
curl -X POST http://localhost:3000/translate \
90+
curl -X POST http://localhost:3220/translate \
6391
-H "Content-Type: application/json" \
6492
-d '{"text": "Hello, world!", "target_lang": "es"}'
6593

6694
# With source language specified
67-
curl -X POST http://localhost:3000/translate \
95+
curl -X POST http://localhost:3220/translate \
6896
-H "Content-Type: application/json" \
6997
-d '{"text": "Bonjour le monde", "source_lang": "fr", "target_lang": "en"}'
7098
```

index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { TranslationRequest } from './providers/index.js';
44
const translationService = new TranslationService();
55

66
const server = Bun.serve({
7-
port: 3000,
7+
port: process.env.PORT ? parseInt(process.env.PORT) : 3220,
88
async fetch(req) {
99
const url = new URL(req.url);
1010

@@ -52,4 +52,4 @@ const server = Bun.serve({
5252
},
5353
});
5454

55-
console.log(`🚀 Server running at http://localhost:${server.port}/`);
55+
console.log(`🗣️ Ready to translate (http://localhost:${server.port})`);

providers/aws.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { TranslationProvider } from './types.js';
2+
import type { TranslationResponse } from './types.js';
3+
4+
interface AWSTranslateResponse {
5+
TranslatedText: string;
6+
SourceLanguageCode: string;
7+
TargetLanguageCode: string;
8+
}
9+
10+
export class AWSProvider extends TranslationProvider {
11+
name = 'aws';
12+
13+
// AWS Translate supported languages
14+
// https://docs.aws.amazon.com/translate/latest/dg/what-is-languages.html
15+
private supportedLanguages = new Set([
16+
'af', 'sq', 'am', 'ar', 'hy', 'az', 'bn', 'bs', 'bg', 'ca', 'zh', 'zh-tw', 'hr', 'cs', 'da', 'fa-af',
17+
'nl', 'en', 'et', 'fa', 'tl', 'fi', 'fr', 'fr-ca', 'ka', 'de', 'el', 'gu', 'ht', 'ha', 'he', 'hi',
18+
'hu', 'is', 'id', 'ga', 'it', 'ja', 'kn', 'kk', 'ko', 'lv', 'lt', 'mk', 'ms', 'ml', 'mt', 'mr', 'mn',
19+
'no', 'ps', 'pl', 'pt', 'pt-pt', 'pa', 'ro', 'ru', 'sr', 'si', 'sk', 'sl', 'so', 'es', 'es-mx', 'sw',
20+
'sv', 'ta', 'te', 'th', 'tr', 'uk', 'ur', 'uz', 'vi', 'cy'
21+
]);
22+
23+
private async sign(stringToSign: string, key: string): Promise<string> {
24+
const encoder = new TextEncoder();
25+
const keyData = encoder.encode(key);
26+
const dataToSign = encoder.encode(stringToSign);
27+
28+
const cryptoKey = await crypto.subtle.importKey(
29+
'raw',
30+
keyData,
31+
{ name: 'HMAC', hash: 'SHA-256' },
32+
false,
33+
['sign']
34+
);
35+
36+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataToSign);
37+
return Array.from(new Uint8Array(signature))
38+
.map(b => b.toString(16).padStart(2, '0'))
39+
.join('');
40+
}
41+
42+
// 💀
43+
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
44+
private async createAuthHeader(
45+
method: string,
46+
url: string,
47+
headers: Record<string, string>,
48+
payload: string,
49+
accessKey: string,
50+
secretKey: string,
51+
region: string
52+
): Promise<string> {
53+
const urlObj = new URL(url);
54+
const host = urlObj.hostname;
55+
const path = urlObj.pathname;
56+
57+
const timestamp = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '');
58+
const date = timestamp.substr(0, 8);
59+
60+
// Create canonical request
61+
const canonicalHeaders = Object.entries(headers)
62+
.map(([key, value]) => `${key.toLowerCase()}:${value}`)
63+
.sort()
64+
.join('\n') + '\n';
65+
66+
const signedHeaders = Object.keys(headers)
67+
.map(key => key.toLowerCase())
68+
.sort()
69+
.join(';');
70+
71+
const payloadHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
72+
const payloadHashHex = Array.from(new Uint8Array(payloadHash))
73+
.map(b => b.toString(16).padStart(2, '0'))
74+
.join('');
75+
76+
const canonicalRequest = [
77+
method,
78+
path,
79+
'', // query string (empty for POST)
80+
canonicalHeaders,
81+
signedHeaders,
82+
payloadHashHex
83+
].join('\n');
84+
85+
// Create string to sign
86+
const algorithm = 'AWS4-HMAC-SHA256';
87+
const credentialScope = `${date}/${region}/translate/aws4_request`;
88+
const canonicalRequestHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalRequest));
89+
const canonicalRequestHashHex = Array.from(new Uint8Array(canonicalRequestHash))
90+
.map(b => b.toString(16).padStart(2, '0'))
91+
.join('');
92+
93+
const stringToSign = [
94+
algorithm,
95+
timestamp,
96+
credentialScope,
97+
canonicalRequestHashHex
98+
].join('\n');
99+
100+
// Calculate signature
101+
const kDate = await this.sign(date, `AWS4${secretKey}`);
102+
const kRegion = await this.sign(region, kDate);
103+
const kService = await this.sign('translate', kRegion);
104+
const kSigning = await this.sign('aws4_request', kService);
105+
const signature = await this.sign(stringToSign, kSigning);
106+
107+
return `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
108+
}
109+
110+
async translate(text: string, targetLang: string, sourceLang?: string): Promise<TranslationResponse> {
111+
const accessKey = process.env.AWS_ACCESS_KEY_ID;
112+
const secretKey = process.env.AWS_SECRET_ACCESS_KEY;
113+
const region = process.env.AWS_REGION || 'us-east-1';
114+
115+
if (!accessKey || !secretKey) {
116+
throw new Error('AWS credentials not configured');
117+
}
118+
119+
const url = `https://translate.${region}.amazonaws.com/`;
120+
const payload = JSON.stringify({
121+
Text: text,
122+
SourceLanguageCode: sourceLang || 'auto',
123+
TargetLanguageCode: targetLang
124+
});
125+
126+
const headers: Record<string, string> = {
127+
'Content-Type': 'application/x-amz-json-1.1',
128+
'X-Amz-Target': 'AWSShineFrontendService_20170701.TranslateText',
129+
'Host': `translate.${region}.amazonaws.com`,
130+
'X-Amz-Date': new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '')
131+
};
132+
133+
const authHeader = await this.createAuthHeader(
134+
'POST',
135+
url,
136+
headers,
137+
payload,
138+
accessKey,
139+
secretKey,
140+
region
141+
);
142+
143+
headers['Authorization'] = authHeader;
144+
145+
const response = await fetch(url, {
146+
method: 'POST',
147+
headers,
148+
body: payload
149+
});
150+
151+
if (!response.ok) {
152+
const errorText = await response.text();
153+
throw new Error(`AWS Translate API error (${response.status}): ${errorText}`);
154+
}
155+
156+
const data = await response.json() as AWSTranslateResponse;
157+
158+
if (!data.TranslatedText) {
159+
throw new Error('Invalid response from AWS Translate API');
160+
}
161+
162+
return {
163+
text: data.TranslatedText,
164+
source_lang: sourceLang || data.SourceLanguageCode || 'auto',
165+
target_lang: targetLang,
166+
provider: this.name
167+
};
168+
}
169+
170+
supportsLanguage(languageCode: string): boolean {
171+
const normalized = languageCode.toLowerCase();
172+
// Handle common variations
173+
if (normalized === 'zh-cn') return this.supportedLanguages.has('zh');
174+
if (normalized === 'zh-tw') return this.supportedLanguages.has('zh-tw');
175+
return this.supportedLanguages.has(normalized);
176+
}
177+
178+
isAvailable(): boolean {
179+
return !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY);
180+
}
181+
}

providers/azure.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { TranslationProvider } from './types.js';
2+
import type { TranslationResponse } from './types.js';
3+
4+
interface AzureTranslateResponse {
5+
translations: Array<{
6+
text: string;
7+
to: string;
8+
}>;
9+
detectedLanguage?: {
10+
language: string;
11+
score: number;
12+
};
13+
}
14+
15+
export class AzureProvider extends TranslationProvider {
16+
name = 'azure';
17+
18+
// Azure Translator supported languages
19+
// https://learn.microsoft.com/en-us/azure/ai-services/translator/language-support
20+
private supportedLanguages = new Set([
21+
'af', 'am', 'ar', 'as', 'az', 'ba', 'bg', 'bn', 'bo', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'dsb', 'dv',
22+
'el', 'en', 'es', 'et', 'eu', 'fa', 'fi', 'fj', 'fo', 'fr', 'fr-ca', 'ga', 'gl', 'gom', 'gu', 'ha',
23+
'he', 'hi', 'hr', 'hsb', 'ht', 'hu', 'hy', 'id', 'ig', 'ikt', 'is', 'it', 'iu', 'iu-latn', 'ja', 'ka',
24+
'kk', 'km', 'kmr', 'kn', 'ko', 'ks', 'ku', 'ky', 'ln', 'lo', 'lt', 'lug', 'lv', 'lzh', 'mai', 'mg',
25+
'mi', 'mk', 'ml', 'mn-cyrl', 'mn-mong', 'mni', 'mr', 'ms', 'mt', 'mww', 'my', 'nb', 'ne', 'nl', 'nso',
26+
'nya', 'or', 'otq', 'pa', 'pl', 'prs', 'ps', 'pt', 'pt-pt', 'ro', 'ru', 'run', 'rw', 'sd', 'si', 'sk',
27+
'sl', 'sm', 'sn', 'so', 'sq', 'sr-cyrl', 'sr-latn', 'st', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti',
28+
'tk', 'tlh-latn', 'tlh-piqd', 'tn', 'to', 'tr', 'tt', 'ty', 'ug', 'uk', 'ur', 'uz', 'vi', 'xh', 'yo',
29+
'yua', 'yue', 'zh-hans', 'zh-hant', 'zu'
30+
]);
31+
32+
async translate(text: string, targetLang: string, sourceLang?: string): Promise<TranslationResponse> {
33+
const apiKey = process.env.AZURE_TRANSLATOR_KEY;
34+
const region = process.env.AZURE_TRANSLATOR_REGION || 'global';
35+
const endpoint = process.env.AZURE_TRANSLATOR_ENDPOINT || 'https://api.cognitive.microsofttranslator.com';
36+
37+
if (!apiKey) {
38+
throw new Error('Azure Translator API key not configured');
39+
}
40+
41+
const url = `${endpoint}/translate?api-version=3.0&to=${targetLang}${sourceLang ? `&from=${sourceLang}` : ''}`;
42+
43+
const headers: Record<string, string> = {
44+
'Ocp-Apim-Subscription-Key': apiKey,
45+
'Content-Type': 'application/json',
46+
};
47+
48+
// Add region header if not global
49+
if (region !== 'global') {
50+
headers['Ocp-Apim-Subscription-Region'] = region;
51+
}
52+
53+
const response = await fetch(url, {
54+
method: 'POST',
55+
headers,
56+
body: JSON.stringify([{ text }])
57+
});
58+
59+
if (!response.ok) {
60+
const errorText = await response.text();
61+
throw new Error(`Azure Translator API error (${response.status}): ${errorText}`);
62+
}
63+
64+
const data = await response.json() as AzureTranslateResponse[];
65+
const result = data[0];
66+
67+
if (!result?.translations?.[0]) {
68+
throw new Error('Invalid response from Azure Translator API');
69+
}
70+
71+
return {
72+
text: result.translations[0].text,
73+
source_lang: sourceLang || result.detectedLanguage?.language || 'auto',
74+
target_lang: targetLang,
75+
provider: this.name
76+
};
77+
}
78+
79+
supportsLanguage(languageCode: string): boolean {
80+
return this.supportedLanguages.has(languageCode.toLowerCase());
81+
}
82+
83+
isAvailable(): boolean {
84+
return !!process.env.AZURE_TRANSLATOR_KEY;
85+
}
86+
}

providers/bing.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { translate } from 'bing-translate-api';
2+
// @ts-ignore - Import JSON file directly
3+
import bingLanguages from 'bing-translate-api/src/lang.json' assert { type: 'json' };
24
import { TranslationProvider } from './types.js';
35
import type { TranslationResponse } from './types.js';
46

57
export class BingTranslateProvider extends TranslationProvider {
68
name = 'bing';
79

10+
// Dynamic language support from Bing Translate API package
11+
private supportedLanguages = new Set(Object.keys(bingLanguages));
12+
813
async translate(text: string, targetLang: string, sourceLang?: string): Promise<TranslationResponse> {
914
// Use null for auto-detect if sourceLang is not provided
1015
const result = await translate(text, sourceLang || null, targetLang);
@@ -20,4 +25,13 @@ export class BingTranslateProvider extends TranslationProvider {
2025
provider: this.name
2126
};
2227
}
28+
29+
supportsLanguage(languageCode: string): boolean {
30+
return this.supportedLanguages.has(languageCode.toLowerCase());
31+
}
32+
33+
isAvailable(): boolean {
34+
// Always available since we don't need API keys
35+
return true;
36+
}
2337
}

0 commit comments

Comments
 (0)