Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 13 additions & 20 deletions packages/core/src/translation/providers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildAITranslationPrompt, microsoftTranslate, toMicrosoftLangCode } from "./providers";
import { buildAITranslationPrompt, googleTranslate, toMicrosoftLangCode } from "./providers";

afterEach(() => {
vi.unstubAllGlobals();
Expand Down Expand Up @@ -36,28 +36,21 @@ describe("Microsoft translator", () => {
expect(toMicrosoftLangCode("ja")).toBe("ja");
});

it("requests Simplified Chinese without an empty source language parameter", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("token"))
.mockResolvedValueOnce(
new Response(JSON.stringify([{ translations: [{ text: "你好" }] }]), {
headers: { "Content-Type": "application/json" },
}),
);
it("uses the keyless Google endpoint and parses translation segments", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify([[["你好", "hello"]], null, "en"]), {
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);

await expect(microsoftTranslate(["hello"], "AUTO", "zh_Hans")).resolves.toEqual(["你好"]);
await expect(googleTranslate(["hello"], "AUTO", "zh-CN")).resolves.toEqual(["你好"]);

const [url, init] = fetchMock.mock.calls[1] as [string, RequestInit];
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const requestUrl = new URL(url);
expect(requestUrl.searchParams.get("api-version")).toBe("3.0");
expect(requestUrl.searchParams.get("to")).toBe("zh-Hans");
expect(requestUrl.searchParams.has("from")).toBe(false);
expect(init.headers).toMatchObject({
"Content-Type": "application/json",
Authorization: "Bearer token",
});
expect(init.body).toBe(JSON.stringify([{ Text: "hello" }]));
expect(requestUrl.hostname).toBe("translate.googleapis.com");
expect(requestUrl.searchParams.get("sl")).toBe("auto");
expect(requestUrl.searchParams.get("tl")).toBe("zh-cn");
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
278 changes: 194 additions & 84 deletions packages/core/src/translation/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,7 @@ export async function aiTranslateBatch(
): Promise<string[]> {
// Single text — just delegate
if (texts.length <= 1) {
return aiTranslate(
texts,
sourceLang,
targetLang,
apiKey,
baseUrl,
model,
useExactRequestUrl,
);
return aiTranslate(texts, sourceLang, targetLang, apiKey, baseUrl, model, useExactRequestUrl);
}

const requestUrl = buildOpenAICompatibleUrl(
Expand Down Expand Up @@ -223,15 +215,7 @@ export async function aiTranslateBatch(
}

// Fallback to individual
return aiTranslate(
texts,
sourceLang,
targetLang,
apiKey,
baseUrl,
model,
useExactRequestUrl,
);
return aiTranslate(texts, sourceLang, targetLang, apiKey, baseUrl, model, useExactRequestUrl);
}

/** Parse "1. xxx\n2. yyy\n..." format into an array */
Expand Down Expand Up @@ -283,14 +267,20 @@ export function getDeepLUrl(baseUrl: string | undefined, path: "translate" | "us
}

function isOfficialDeepLHost(hostname: string): boolean {
return hostname === "api.deepl.com" || hostname === "api-free.deepl.com" || hostname.endsWith(".deepl.com");
return (
hostname === "api.deepl.com" ||
hostname === "api-free.deepl.com" ||
hostname.endsWith(".deepl.com")
);
}

function resolveDeepLConfig(baseUrl: string | undefined, apiKey: string): ResolvedDeepLConfig {
const rawBaseUrl = baseUrl?.trim();
const normalizedBaseUrl = normalizeDeepLBaseUrl(rawBaseUrl);
const url = new URL(normalizedBaseUrl);
const rawPathSegments = (rawBaseUrl ? new URL(rawBaseUrl) : url).pathname.split("/").filter(Boolean);
const rawPathSegments = (rawBaseUrl ? new URL(rawBaseUrl) : url).pathname
.split("/")
.filter(Boolean);
const pathSegments = [...rawPathSegments];
const hasTranslateSuffix = (rawBaseUrl || "").replace(/\/+$/, "").endsWith("/translate");
const exactTranslateUrl = hasTranslateSuffix ? (rawBaseUrl || "").replace(/\/+$/, "") : undefined;
Expand Down Expand Up @@ -329,7 +319,12 @@ function resolveDeepLConfig(baseUrl: string | undefined, apiKey: string): Resolv
}

function extractDeepLXTranslation(data: any): string | null {
const candidate = typeof data?.data === "string" ? data.data : typeof data?.translation === "string" ? data.translation : null;
const candidate =
typeof data?.data === "string"
? data.data
: typeof data?.translation === "string"
? data.translation
: null;
if (!candidate) {
return null;
}
Expand Down Expand Up @@ -549,9 +544,6 @@ export const deeplProvider: TranslationProvider = {

// ─── Microsoft Edge Translate (Free, no API key required) ─────────────────────

let _msToken: string | null = null;
let _msTokenExpiry = 0;

/** Language code mapping: our codes → Microsoft API codes */
export function toMicrosoftLangCode(lang: string): string {
const normalized = lang.trim().replace(/_/g, "-");
Expand All @@ -569,28 +561,187 @@ export function toMicrosoftLangCode(lang: string): string {

/** Microsoft supported source languages (subset for validation) */
const MS_SUPPORTED_LANGS = new Set([
"af", "am", "ar", "as", "az", "ba", "bg", "bn", "bo", "bs", "ca", "cs", "cy", "da", "de",
"dv", "el", "en", "es", "et", "eu", "fa", "fi", "fil", "fj", "fo", "fr", "ga", "gl", "gu",
"ha", "he", "hi", "hr", "ht", "hu", "hy", "id", "ig", "ikt", "is", "it", "iu", "ja", "ka",
"kk", "km", "kn", "ko", "ku", "ky", "ln", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn",
"mr", "ms", "mt", "my", "nb", "ne", "nl", "no", "or", "pa", "pl", "ps", "pt", "ro", "ru",
"rw", "sd", "si", "sk", "sl", "sm", "sn", "so", "sq", "sr", "st", "sv", "sw", "ta", "te",
"th", "ti", "tk", "tl", "tn", "to", "tr", "tt", "ty", "ug", "uk", "ur", "uz", "vi", "xh",
"yo", "yue", "zh-Hans", "zh-Hant", "zu",
"af",
"am",
"ar",
"as",
"az",
"ba",
"bg",
"bn",
"bo",
"bs",
"ca",
"cs",
"cy",
"da",
"de",
"dv",
"el",
"en",
"es",
"et",
"eu",
"fa",
"fi",
"fil",
"fj",
"fo",
"fr",
"ga",
"gl",
"gu",
"ha",
"he",
"hi",
"hr",
"ht",
"hu",
"hy",
"id",
"ig",
"ikt",
"is",
"it",
"iu",
"ja",
"ka",
"kk",
"km",
"kn",
"ko",
"ku",
"ky",
"ln",
"lo",
"lt",
"lv",
"mg",
"mi",
"mk",
"ml",
"mn",
"mr",
"ms",
"mt",
"my",
"nb",
"ne",
"nl",
"no",
"or",
"pa",
"pl",
"ps",
"pt",
"ro",
"ru",
"rw",
"sd",
"si",
"sk",
"sl",
"sm",
"sn",
"so",
"sq",
"sr",
"st",
"sv",
"sw",
"ta",
"te",
"th",
"ti",
"tk",
"tl",
"tn",
"to",
"tr",
"tt",
"ty",
"ug",
"uk",
"ur",
"uz",
"vi",
"xh",
"yo",
"yue",
"zh-Hans",
"zh-Hant",
"zu",
]);

/** Get or refresh the free Microsoft Edge translate JWT token */
async function getMicrosoftToken(): Promise<string> {
if (_msToken && Date.now() < _msTokenExpiry) return _msToken;
const GOOGLE_TRANSLATE_URL = "https://translate.googleapis.com/translate_a/single";
const GOOGLE_MAX_CHARS = 4500;

function googleLangCode(lang: string, fallback: string): string {
const normalized = (lang || fallback).replace("_", "-").toLowerCase();
if (!normalized || normalized === "auto") return "auto";
return normalized;
}

const resp = await fetch("https://edge.microsoft.com/translate/auth");
if (!resp.ok) {
throw new Error(`Failed to get Microsoft translate token: ${resp.status}`);
async function googleTranslateChunk(
text: string,
sourceLang: string,
targetLang: string,
): Promise<string> {
const params = new URLSearchParams({
client: "gtx",
sl: googleLangCode(sourceLang, "auto"),
tl: googleLangCode(targetLang, "en"),
dt: "t",
q: text,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch(`${GOOGLE_TRANSLATE_URL}?${params}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error(`Google translate failed: ${response.status}`);
const data = (await response.json()) as unknown;
const translated =
Array.isArray(data) && Array.isArray(data[0])
? data[0]
.map((part) => (Array.isArray(part) && typeof part[0] === "string" ? part[0] : ""))
.join("")
: "";
if (!translated) throw new Error("Google translate returned an empty result");
return translated;
} catch (error) {
if (error instanceof Error && error.name === "AbortError")
throw new Error("Google translate timed out");
throw error;
} finally {
clearTimeout(timeout);
}
_msToken = await resp.text();
// Token valid ~10 min, refresh at 8 min
_msTokenExpiry = Date.now() + 8 * 60 * 1000;
return _msToken;
}

export async function googleTranslate(
texts: string[],
sourceLang: string,
targetLang: string,
): Promise<string[]> {
const results: string[] = [];
for (const text of texts) {
if (!text.trim()) {
results.push("");
continue;
}
const chunks = [];
for (let offset = 0; offset < text.length; offset += GOOGLE_MAX_CHARS)
chunks.push(text.slice(offset, offset + GOOGLE_MAX_CHARS));
results.push(
(
await Promise.all(
chunks.map((chunk) => googleTranslateChunk(chunk, sourceLang, targetLang)),
)
).join(""),
);
}
return results;
}

/**
Expand All @@ -602,48 +753,7 @@ export async function microsoftTranslate(
sourceLang: string,
targetLang: string,
): Promise<string[]> {
const token = await getMicrosoftToken();
const mappedSource = toMicrosoftLangCode(sourceLang);
// If source lang is "auto"/"AUTO", empty, or not recognized by Microsoft, omit it for auto-detection
const from = (!sourceLang || sourceLang.toLowerCase() === "auto" || !MS_SUPPORTED_LANGS.has(mappedSource)) ? "" : mappedSource;
const to = toMicrosoftLangCode(targetLang);
const params = new URLSearchParams({
"api-version": "3.0",
to,
});
if (from) {
params.set("from", from);
}

const body = texts.map((t) => ({ Text: t }));

const resp = await fetch(
`https://api-edge.cognitive.microsofttranslator.com/translate?${params.toString()}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
},
);

if (!resp.ok) {
// If 401, invalidate token for retry on next call
if (resp.status === 401) {
_msToken = null;
_msTokenExpiry = 0;
}
const errText = await resp.text().catch(() => "");
throw new Error(`Microsoft translate failed: ${resp.status} ${errText}`);
}

const result = (await resp.json()) as Array<{
translations: Array<{ text: string }>;
}>;

return result.map((r) => r.translations?.[0]?.text ?? "");
return googleTranslate(texts, sourceLang, targetLang);
}

export const microsoftProvider: TranslationProvider = {
Expand Down