Skip to content

Commit 58a6433

Browse files
committed
ML Update
1 parent d10b6bb commit 58a6433

7 files changed

Lines changed: 240 additions & 5 deletions

File tree

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
import type { VercelRequest, VercelResponse } from "@vercel/node";
22

3-
export default function handler(_req: VercelRequest, res: VercelResponse) {
4-
res.status(200).json({ status: "ok" });
3+
const VERSION = "2.1.0";
4+
5+
export default async function handler(_req: VercelRequest, res: VercelResponse) {
6+
const google = !!process.env.GOOGLE_API_KEY;
7+
const deepl = !!process.env.DEEPL_API_KEY;
8+
const ml = !!(process.env.ML_SERVER && process.env.ML_API_KEY);
9+
10+
res.status(200).json({
11+
status: "ok",
12+
version: VERSION,
13+
engines: {
14+
google: google ? "connected" : "not configured",
15+
ml: ml ? "connected" : "not configured",
16+
deepl: deepl ? "connected" : "not configured",
17+
},
18+
});
519
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { VercelRequest, VercelResponse } from "@vercel/node";
2+
3+
const VALID_LANG_CODES = new Set([
4+
"en", "de", "fr", "es", "zh-CN", "zh-TW", "ru", "it", "pt", "ro",
5+
"ja", "ko", "vi", "id", "bg", "cs",
6+
"EN", "DE", "FR", "ES", "ZH", "ZH-CN", "ZH-TW", "RU", "IT", "PT", "RO",
7+
"JA", "KO", "VI", "ID", "BG", "CS",
8+
"EN-GB", "PT-PT", "ZH-HANS", "ZH-HANT",
9+
]);
10+
11+
const MAX_TEXT_LENGTH = 200;
12+
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
13+
14+
function checkRateLimit(ip: string): boolean {
15+
const now = Date.now();
16+
const entry = rateLimitMap.get(ip);
17+
if (!entry || now > entry.resetAt) {
18+
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 });
19+
return true;
20+
}
21+
if (entry.count >= 10) return false;
22+
entry.count++;
23+
return true;
24+
}
25+
26+
export default async function handler(req: VercelRequest, res: VercelResponse) {
27+
res.setHeader("Access-Control-Allow-Origin", "*");
28+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
29+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
30+
31+
if (req.method === "OPTIONS") {
32+
res.status(200).end();
33+
return;
34+
}
35+
36+
if (req.method !== "POST") {
37+
res.status(405).json({ error: "Method not allowed" });
38+
return;
39+
}
40+
41+
const ip = (req.headers["x-forwarded-for"] as string || "unknown").split(",")[0].trim();
42+
if (!checkRateLimit(ip)) {
43+
res.status(429).json({ error: "Rate limit exceeded. Please try again later." });
44+
return;
45+
}
46+
47+
const { text, source, target } = req.body || {};
48+
49+
if (typeof text !== "string" || !text.trim()) {
50+
res.status(400).json({ error: "Missing or empty 'text' field" });
51+
return;
52+
}
53+
if (text.length > MAX_TEXT_LENGTH) {
54+
res.status(400).json({ error: `Text exceeds maximum length of ${MAX_TEXT_LENGTH} characters` });
55+
return;
56+
}
57+
if (typeof source !== "string" || !VALID_LANG_CODES.has(source)) {
58+
res.status(400).json({ error: `Invalid 'source' language code: ${source}` });
59+
return;
60+
}
61+
if (typeof target !== "string" || !VALID_LANG_CODES.has(target)) {
62+
res.status(400).json({ error: `Invalid 'target' language code: ${target}` });
63+
return;
64+
}
65+
66+
const mlServer = process.env["ML_SERVER"];
67+
const apiKey = process.env["ML_API_KEY"];
68+
if (!mlServer || !apiKey) {
69+
res.status(500).json({ error: "Translation service is not configured" });
70+
return;
71+
}
72+
73+
try {
74+
const params = new URLSearchParams({
75+
API_KEY: apiKey,
76+
langTo: target.toLowerCase(),
77+
langFrom: source.toLowerCase(),
78+
});
79+
80+
const response = await fetch(`${mlServer}/translate?${params.toString()}`, {
81+
method: "POST",
82+
headers: { "Content-Type": "application/json; charset=utf-8" },
83+
body: JSON.stringify({ "X-Translate-Text": text.trim() }),
84+
});
85+
86+
if (!response.ok) {
87+
const errorBody = await response.text();
88+
console.error("ML API error", response.status, errorBody);
89+
res.status(502).json({ error: "Translation service returned an error" });
90+
return;
91+
}
92+
93+
const data: any = await response.json();
94+
const translated = data?.[1]?.data?.translations?.[0]?.translatedText;
95+
96+
if (!translated) {
97+
console.error("Unexpected ML response format", JSON.stringify(data));
98+
res.status(502).json({ error: "Unexpected response from translation service" });
99+
return;
100+
}
101+
102+
res.status(200).json({ translation: translated });
103+
} catch (err) {
104+
console.error("ML request failed", err);
105+
res.status(502).json({ error: "Failed to reach translation service" });
106+
}
107+
}

artifacts/api-server/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@workspace/api-server",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "export NODE_ENV=development && pnpm run build && pnpm run start",
8+
"build": "node ./build.mjs",
9+
"start": "node --enable-source-maps ./dist/index.mjs",
10+
"typecheck": "tsc -p tsconfig.json --noEmit"
11+
},
12+
"dependencies": {
13+
"cookie-parser": "^1.4.7",
14+
"cors": "^2",
15+
"express": "^5",
16+
"pino": "^9",
17+
"pino-http": "^10"
18+
},
19+
"devDependencies": {
20+
"@types/cookie-parser": "^1.4.10",
21+
"@types/cors": "^2.8.19",
22+
"@types/express": "^5.0.6",
23+
"@types/node": "^22.0.0",
24+
"esbuild": "0.25.4",
25+
"esbuild-plugin-pino": "^2.3.3",
26+
"pino-pretty": "^13",
27+
"thread-stream": "3.1.0"
28+
}
29+
}
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import { Router, type IRouter } from "express";
22

3+
const VERSION = "2.1.0";
4+
35
const router: IRouter = Router();
46

57
router.get("/healthz", (_req, res) => {
6-
res.json({ status: "ok" });
8+
const google = !!process.env.GOOGLE_API_KEY;
9+
const deepl = !!process.env.DEEPL_API_KEY;
10+
const ml = !!(process.env.ML_SERVER && process.env.ML_API_KEY);
11+
12+
res.json({
13+
status: "ok",
14+
version: VERSION,
15+
engines: {
16+
google: google ? "connected" : "not configured",
17+
ml: ml ? "connected" : "not configured",
18+
deepl: deepl ? "connected" : "not configured",
19+
},
20+
});
721
});
822

923
export default router;

artifacts/api-server/src/routes/translate.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,61 @@ router.post("/translate/deepl", async (req: Request, res: ExpressResponse) => {
186186
}
187187
});
188188

189+
router.post("/translate/ml", async (req: Request, res: ExpressResponse) => {
190+
const ip = getClientIp(req);
191+
if (!checkServerRateLimit(ip)) {
192+
res.status(429).json({ error: "Rate limit exceeded. Please try again later." });
193+
return;
194+
}
195+
196+
const validation = validateBody(req.body as Record<string, unknown>);
197+
if (typeof validation === "string") {
198+
res.status(400).json({ error: validation });
199+
return;
200+
}
201+
202+
const mlServer = process.env["ML_SERVER"];
203+
const apiKey = process.env["ML_API_KEY"];
204+
if (!mlServer || !apiKey) {
205+
logger.error("ML_SERVER or ML_API_KEY is not configured");
206+
res.status(500).json({ error: "Translation service is not configured" });
207+
return;
208+
}
209+
210+
try {
211+
const params = new URLSearchParams({
212+
API_KEY: apiKey,
213+
langTo: validation.target.toLowerCase(),
214+
langFrom: validation.source.toLowerCase(),
215+
});
216+
217+
const response = await httpPost(
218+
`${mlServer}/translate?${params.toString()}`,
219+
{ "Content-Type": "application/json; charset=utf-8" },
220+
JSON.stringify({ "X-Translate-Text": validation.text })
221+
);
222+
223+
if (!response.ok) {
224+
const errorBody = await response.text();
225+
logger.error({ status: response.status, body: errorBody }, "ML API error");
226+
res.status(502).json({ error: "Translation service returned an error" });
227+
return;
228+
}
229+
230+
const data = await response.json() as [unknown, { data?: { translations?: Array<{ translatedText?: string }> } }];
231+
232+
const translated = data?.[1]?.data?.translations?.[0]?.translatedText;
233+
if (!translated) {
234+
logger.error({ data }, "Unexpected ML response format");
235+
res.status(502).json({ error: "Unexpected response from translation service" });
236+
return;
237+
}
238+
239+
res.json({ translation: translated });
240+
} catch (err) {
241+
logger.error({ err }, "ML request failed");
242+
res.status(502).json({ error: "Failed to reach translation service" });
243+
}
244+
});
245+
189246
export default router;

artifacts/api-server/vercel.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"version": 2,
3+
"installCommand": "npm install --force",
4+
"buildCommand": "echo 'no build needed'",
5+
"outputDirectory": ".",
36
"headers": [
47
{
58
"source": "/api/(.*)",

artifacts/ritabot-homepage/src/pages/Engines.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,19 @@ async function translateWithGoogle(text: string, source: Language, target: Langu
5757
return data.translation;
5858
}
5959

60-
async function translateWithML(_text: string, _source: Language, _target: Language): Promise<string> {
61-
return "ML Engine translation is not yet available. Details coming soon.";
60+
async function translateWithML(text: string, source: Language, target: Language): Promise<string> {
61+
const res = await fetch(`${API_BASE_URL}/api/translate/ml`, {
62+
method: "POST",
63+
headers: { "Content-Type": "application/json" },
64+
body: JSON.stringify({
65+
text,
66+
source: source.code,
67+
target: target.code,
68+
}),
69+
});
70+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
71+
const data = await res.json();
72+
return data.translation;
6273
}
6374

6475
async function translateWithDeepL(text: string, source: Language, target: Language): Promise<string> {

0 commit comments

Comments
 (0)