diff --git a/src/__tests__/baidu.test.ts b/src/__tests__/baidu.test.ts new file mode 100644 index 0000000..997d6e6 --- /dev/null +++ b/src/__tests__/baidu.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, vi } from "vitest"; +import { + BAIDU_ENDPOINT, + BaiduProvider, + buildBaiduRequest, + buildBaiduSign, + makeSalt, + packBaiduBatch, + parseBaiduResponse, + toBaiduLangCode, + unpackBaiduTransResult, +} from "../translator/baidu"; +import { md5Hex } from "../translator/md5"; +import { + AuthError, + HttpRequestSpec, + HttpResponseLike, + MalformedResponseError, + ProviderConfig, + RateLimitError, + SegmentCountMismatchError, +} from "../translator/provider"; + +const cfg = (over: Partial = {}): ProviderConfig => ({ + // Baidu: apiKey=secret, baseUrl=APP ID, model=IGNORED. + apiKey: "654781234567890", + baseUrl: "2015063000000001", + model: "", + targetLang: "zh-CN", + ...over, +}); + +describe("toBaiduLangCode", () => { + it("maps BCP-47 codes to Baidu's language codes (case-insensitive)", () => { + expect(toBaiduLangCode("zh-CN")).toBe("zh"); + expect(toBaiduLangCode("zh")).toBe("zh"); + expect(toBaiduLangCode("zh-TW")).toBe("cht"); + expect(toBaiduLangCode("zh-Hant")).toBe("cht"); + expect(toBaiduLangCode("ja")).toBe("jp"); + expect(toBaiduLangCode("ko")).toBe("kor"); + expect(toBaiduLangCode("fr")).toBe("fra"); + expect(toBaiduLangCode("FR")).toBe("fra"); + expect(toBaiduLangCode("pt-BR")).toBe("pt"); + }); + + it("falls back to the BCP-47 base subtag for shared codes (en, de, it, ...)", () => { + // Baidu uses the same code as BCP-47 for these — pass-through. + expect(toBaiduLangCode("en")).toBe("en"); + expect(toBaiduLangCode("en-US")).toBe("en"); + expect(toBaiduLangCode("de")).toBe("de"); + expect(toBaiduLangCode("it")).toBe("it"); + }); + + it("passes unknown codes through as the base subtag (Baidu will 58001 if unsupported)", () => { + expect(toBaiduLangCode("xx-YY")).toBe("xx"); + expect(toBaiduLangCode(" eo ")).toBe("eo"); + }); +}); + +describe("buildBaiduSign (concat order + md5 wiring)", () => { + // Regression guard against md5 / concat / encoding drift. `md5Hex` itself is + // covered by md5.test.ts (including the docs' canonical `apple` example), so + // here we only pin the CONCAT ORDER: appid + q + salt + secret. + it("md5(appid + q + salt + secret) — order is appid,q,salt,secret", () => { + const appid = "2015063000000001"; + const q = "apple"; + const salt = "1435660288"; + const secret = "12345678"; + const sign = buildBaiduSign(appid, q, salt, secret); + expect(sign).toBe(md5Hex(appid + q + salt + secret)); + // Also confirm the order is not one of the plausible transpositions. + expect(sign).not.toBe(md5Hex(secret + q + salt + appid)); + expect(sign).not.toBe(md5Hex(appid + salt + q + secret)); + }); +}); + +describe("makeSalt", () => { + it("uses injected now()/rand() so tests are deterministic", () => { + const salt = makeSalt( + () => 1700000000000, + () => 0.5 + ); + // `${1700000000000}${floor(0.5 * 1e6)}` -> "1700000000000500000" + expect(salt).toBe("1700000000000500000"); + }); + + it("generates a non-empty ASCII string with random source", () => { + const s = makeSalt(); + expect(s.length).toBeGreaterThan(0); + expect(/^[0-9]+$/.test(s)).toBe(true); + }); +}); + +describe("buildBaiduRequest", () => { + it("posts application/x-www-form-urlencoded to the docs' endpoint", () => { + const req = buildBaiduRequest("apple", cfg(), { salt: "1435660288" }); + expect(req.url).toBe(BAIDU_ENDPOINT); + expect(req.method).toBe("POST"); + expect(req.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + }); + + it("includes q/from/to/appid/salt/sign — 'to' derived from targetLang, 'from' auto", () => { + const req = buildBaiduRequest("apple", cfg({ targetLang: "ja" }), { salt: "s" }); + const params = new URLSearchParams(req.body); + expect(params.get("q")).toBe("apple"); + expect(params.get("from")).toBe("auto"); + expect(params.get("to")).toBe("jp"); + expect(params.get("appid")).toBe("2015063000000001"); + expect(params.get("salt")).toBe("s"); + expect(params.get("sign")).toBe(buildBaiduSign("2015063000000001", "apple", "s", "654781234567890")); + }); + + it("URL-encodes newlines/spaces in q while the SIGN uses the raw text (54001 guard)", () => { + const q = "hello world\nsecond line"; + const req = buildBaiduRequest(q, cfg(), { salt: "s" }); + // Sign is over the RAW q, not the encoded form — per docs §54001 troubleshooting. + const expectedSign = buildBaiduSign("2015063000000001", q, "s", "654781234567890"); + const params = new URLSearchParams(req.body); + expect(params.get("sign")).toBe(expectedSign); + // urlencoded form uses '+' for spaces, '%0A' for LF (via the decoder both work). + expect(params.get("q")).toBe(q); + // And the raw body indeed uses '+' rather than %20 for the space. + expect(req.body).toContain("q=hello+world"); + }); + + it("trims the APP ID (baseUrl) before signing so trailing whitespace can't break the sign", () => { + const req = buildBaiduRequest("x", cfg({ baseUrl: " 2015063000000001 " }), { salt: "s" }); + const params = new URLSearchParams(req.body); + expect(params.get("appid")).toBe("2015063000000001"); + expect(params.get("sign")).toBe( + buildBaiduSign("2015063000000001", "x", "s", "654781234567890") + ); + }); +}); + +describe("packBaiduBatch / unpackBaiduTransResult", () => { + it("joins segments with LF and records per-segment line counts", () => { + const { q, lineCounts } = packBaiduBatch(["one", "two\nlines", "three"]); + expect(q).toBe("one\ntwo\nlines\nthree"); + expect(lineCounts).toEqual([1, 2, 1]); + }); + + it("regroups a flat dst[] back into per-segment translations", () => { + // Baidu returns one trans_result entry per input LINE, not per source segment. + const dsts = ["一", "二", "行", "三"]; + const out = unpackBaiduTransResult(dsts, [1, 2, 1]); + expect(out).toEqual(["一", "二\n行", "三"]); + }); + + it("throws SegmentCountMismatchError when the totals disagree", () => { + expect(() => unpackBaiduTransResult(["a", "b"], [1, 2, 1])).toThrowError(SegmentCountMismatchError); + }); + + it("empty lines in the source count as lines too (Baidu returns one dst per input line)", () => { + const { q, lineCounts } = packBaiduBatch(["a\n\nb"]); + expect(q).toBe("a\n\nb"); + expect(lineCounts).toEqual([3]); + expect(unpackBaiduTransResult(["甲", "", "乙"], lineCounts)).toEqual(["甲\n\n乙"]); + }); +}); + +describe("parseBaiduResponse", () => { + const ok = (body: unknown): HttpResponseLike => ({ + status: 200, + text: JSON.stringify(body), + json: body, + }); + + it("returns the flat dst[] on success (error_code omitted)", () => { + const out = parseBaiduResponse( + ok({ + from: "en", + to: "zh", + trans_result: [ + { src: "apple", dst: "苹果" }, + { src: "banana", dst: "香蕉" }, + ], + }) + ); + expect(out).toEqual(["苹果", "香蕉"]); + }); + + it("accepts error_code === '0' / '52000' as success", () => { + const out = parseBaiduResponse( + ok({ error_code: "52000", trans_result: [{ src: "x", dst: "y" }] }) + ); + expect(out).toEqual(["y"]); + const out2 = parseBaiduResponse( + ok({ error_code: 0, trans_result: [{ src: "x", dst: "y" }] }) + ); + expect(out2).toEqual(["y"]); + }); + + it("throws AuthError for 52003 / 54001 / 54004 / 58000 / 58002 / 58003 / 90107", () => { + for (const code of ["52003", "54001", "54004", "58000", "58002", "58003", "90107"]) { + expect(() => parseBaiduResponse(ok({ error_code: code, error_msg: "x" }))).toThrowError(AuthError); + } + }); + + it("throws RateLimitError for 52001 / 52002 / 54003 / 54005 (retryable)", () => { + for (const code of ["52001", "52002", "54003", "54005"]) { + let thrown: unknown; + try { + parseBaiduResponse(ok({ error_code: code, error_msg: "x" })); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(RateLimitError); + // Retryable so the pool retries with its exponential backoff. + expect((thrown as RateLimitError).retryable).toBe(true); + } + }); + + it("throws MalformedResponseError for other error codes (54000 / 58001 / 20003 / unknown)", () => { + for (const code of ["54000", "58001", "20003", "99999"]) { + expect(() => parseBaiduResponse(ok({ error_code: code, error_msg: "x" }))).toThrowError( + MalformedResponseError + ); + } + }); + + it("throws MalformedResponseError on non-2xx / non-JSON / missing trans_result", () => { + expect(() => parseBaiduResponse({ status: 500, text: "oops" })).toThrowError(MalformedResponseError); + expect(() => parseBaiduResponse({ status: 200, text: "" })).toThrowError(MalformedResponseError); + expect(() => parseBaiduResponse(ok({}))).toThrowError(MalformedResponseError); // no trans_result + expect(() => parseBaiduResponse(ok({ trans_result: [{ src: "x" }] }))).toThrowError( + MalformedResponseError + ); // dst missing + }); + + it("falls back to JSON.parse(text) when res.json is not populated", () => { + const body = { trans_result: [{ src: "x", dst: "y" }] }; + const out = parseBaiduResponse({ status: 200, text: JSON.stringify(body) }); + expect(out).toEqual(["y"]); + }); +}); + +describe("BaiduProvider.translate", () => { + const okBody = (dsts: string[]): HttpResponseLike => { + const body = { trans_result: dsts.map((d, i) => ({ src: `s${i}`, dst: d })) }; + return { status: 200, text: JSON.stringify(body), json: body }; + }; + + it("returns [] for no segments without calling the client", async () => { + const http = vi.fn(async (_req: HttpRequestSpec): Promise => okBody([])); + const provider = new BaiduProvider({ config: cfg(), http, salt: () => "s" }); + expect(await provider.translate([])).toEqual([]); + expect(http).not.toHaveBeenCalled(); + }); + + it("translates a batch in a single request (one dst per source line)", async () => { + const http = vi.fn(async (_req: HttpRequestSpec): Promise => + okBody(["苹果", "香蕉"]) + ); + const provider = new BaiduProvider({ config: cfg(), http, salt: () => "s" }); + expect(await provider.translate(["apple", "banana"])).toEqual(["苹果", "香蕉"]); + expect(http).toHaveBeenCalledTimes(1); + }); + + it("regroups multi-line segments back into per-segment translations", async () => { + // Input has 2 segments, but 3 total lines -> 3 dst entries -> regroup to 2 outs. + const http = vi.fn(async (_req: HttpRequestSpec): Promise => + okBody(["一", "二", "三"]) + ); + const provider = new BaiduProvider({ config: cfg(), http, salt: () => "s" }); + expect(await provider.translate(["one", "two\nthree"])).toEqual(["一", "二\n三"]); + }); + + it("falls back to per-segment requests when the batch line count mismatches", async () => { + // First (batch) returns too few dsts -> mismatch -> per-segment fallback recovers. + const responses: HttpResponseLike[] = [ + okBody(["only-one"]), // 2 expected, got 1 -> mismatch + okBody(["苹果"]), + okBody(["香蕉"]), + ]; + let call = 0; + const http = vi.fn(async (_req: HttpRequestSpec): Promise => responses[call++]); + const provider = new BaiduProvider({ config: cfg(), http, salt: () => "s" }); + expect(await provider.translate(["apple", "banana"])).toEqual(["苹果", "香蕉"]); + expect(http).toHaveBeenCalledTimes(3); + }); + + it("propagates AuthError without falling back", async () => { + const http = vi.fn(async (_req: HttpRequestSpec): Promise => ({ + status: 200, + text: JSON.stringify({ error_code: "54001", error_msg: "sign error" }), + json: { error_code: "54001", error_msg: "sign error" }, + })); + const provider = new BaiduProvider({ config: cfg(), http, salt: () => "s" }); + await expect(provider.translate(["a", "b"])).rejects.toBeInstanceOf(AuthError); + expect(http).toHaveBeenCalledTimes(1); + }); + + it("propagates RateLimitError so the pool can retry (no fallback)", async () => { + const http = vi.fn(async (_req: HttpRequestSpec): Promise => ({ + status: 200, + text: JSON.stringify({ error_code: "54003", error_msg: "qps limit" }), + json: { error_code: "54003", error_msg: "qps limit" }, + })); + const provider = new BaiduProvider({ config: cfg(), http, salt: () => "s" }); + await expect(provider.translate(["a", "b"])).rejects.toBeInstanceOf(RateLimitError); + expect(http).toHaveBeenCalledTimes(1); + }); + + it("sends a fresh salt per request via the injected salt fn", async () => { + const salts = ["s1", "s2", "s3"]; + let idx = 0; + const seen: string[] = []; + const http = vi.fn(async (req: HttpRequestSpec): Promise => { + const params = new URLSearchParams(req.body); + seen.push(params.get("salt") || ""); + // Force the fallback path so we make three calls total. + if (seen.length === 1) return okBody(["only"]); + return okBody(["译"]); + }); + const provider = new BaiduProvider({ + config: cfg(), + http, + salt: () => salts[idx++] ?? "s", + }); + await provider.translate(["a", "b"]); + // Batch + 2 per-segment retries — each with its own salt. + expect(seen).toEqual(["s1", "s2", "s3"]); + }); + + it("does not fall back when there is only one segment (mismatch is terminal)", async () => { + // Single-segment translate + docs return 0 lines -> mismatch, no fallback. + const http = vi.fn(async (_req: HttpRequestSpec): Promise => okBody([])); + const provider = new BaiduProvider({ config: cfg(), http, salt: () => "s" }); + await expect(provider.translate(["a"])).rejects.toBeInstanceOf(SegmentCountMismatchError); + expect(http).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/factory.test.ts b/src/__tests__/factory.test.ts new file mode 100644 index 0000000..88601a4 --- /dev/null +++ b/src/__tests__/factory.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; +import { createProvider } from "../translator/factory"; +import { BaiduProvider, BAIDU_ENDPOINT } from "../translator/baidu"; +import { DeepSeekProvider } from "../translator/deepseek"; +import { normalizeSettings } from "../settings"; +import { HttpRequestSpec, HttpResponseLike } from "../translator/provider"; + +describe("createProvider", () => { + it("dispatches to DeepSeekProvider for providerKind === 'openai' (default)", () => { + const settings = normalizeSettings({ apiKey: "sk", baseUrl: "https://api.deepseek.com", model: "x" }); + const http = vi.fn(async (_req: HttpRequestSpec): Promise => ({ status: 200, text: "" })); + const provider = createProvider(settings, http); + expect(provider).toBeInstanceOf(DeepSeekProvider); + expect(provider).not.toBeInstanceOf(BaiduProvider); + }); + + it("dispatches to BaiduProvider for providerKind === 'baidu'", () => { + const settings = normalizeSettings({ + providerKind: "baidu", + apiKey: "secret", + baseUrl: "2015063000000001", + model: "ignored", + }); + const http = vi.fn(async (_req: HttpRequestSpec): Promise => ({ status: 200, text: "" })); + const provider = createProvider(settings, http); + expect(provider).toBeInstanceOf(BaiduProvider); + expect(provider).not.toBeInstanceOf(DeepSeekProvider); + }); + + it("Baidu-dispatched provider hits the Baidu wire endpoint (not the OpenAI base URL)", async () => { + // End-to-end confirmation: even with baseUrl carrying an APP ID, the + // provider sends to fanyi-api.baidu.com — the wire endpoint is hard-coded + // in the Baidu provider and the baseUrl-as-APP-ID is used only for signing. + const settings = normalizeSettings({ + providerKind: "baidu", + apiKey: "secret", + baseUrl: "2015063000000001", + }); + let seenUrl = ""; + let seenBody = ""; + const http = vi.fn(async (req: HttpRequestSpec): Promise => { + seenUrl = req.url; + seenBody = req.body; + const body = { trans_result: [{ src: "hi", dst: "你好" }] }; + return { status: 200, text: JSON.stringify(body), json: body }; + }); + const provider = createProvider(settings, http); + expect(await provider.translate(["hi"])).toEqual(["你好"]); + expect(seenUrl).toBe(BAIDU_ENDPOINT); + expect(seenBody).toContain("appid=2015063000000001"); + }); +}); diff --git a/src/__tests__/md5.test.ts b/src/__tests__/md5.test.ts new file mode 100644 index 0000000..5e544e3 --- /dev/null +++ b/src/__tests__/md5.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { md5Hex } from "../translator/md5"; + +describe("md5Hex (RFC 1321 test vectors)", () => { + it("hashes the empty string", () => { + expect(md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e"); + }); + + it("hashes single-character 'a'", () => { + expect(md5Hex("a")).toBe("0cc175b9c0f1b6a831c399e269772661"); + }); + + it("hashes 'abc'", () => { + expect(md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72"); + }); + + it("hashes 'message digest'", () => { + expect(md5Hex("message digest")).toBe("f96b697d7cb7938d525a2f31aaf161d0"); + }); + + it("hashes the alphabet", () => { + expect(md5Hex("abcdefghijklmnopqrstuvwxyz")).toBe("c3fcd3d76192e4007dfb496cca67e13b"); + }); + + it("hashes alnum (62 chars) — spans exactly one 64-byte block boundary", () => { + expect(md5Hex("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")).toBe( + "d174ab98d277d9f5a5611c2c9f419d9f" + ); + }); + + it("hashes eight repeated digit-runs (80 chars) — spans two blocks", () => { + expect( + md5Hex("12345678901234567890123456789012345678901234567890123456789012345678901234567890") + ).toBe("57edf4a22be3c955ac49da2e2107b67a"); + }); +}); + +describe("md5Hex (UTF-8)", () => { + it("hashes CJK characters as their UTF-8 byte sequence", () => { + // md5(0xe4 0xbd 0xa0 0xe5 0xa5 0xbd) = 7eca689f0d3389d9dea66ae112e5cfd7 + expect(md5Hex("你好")).toBe("7eca689f0d3389d9dea66ae112e5cfd7"); + }); +}); + +describe("md5Hex (Baidu docs canonical example)", () => { + // From the Baidu translate API docs: appid+q+salt+appkey = "2015063000000001apple654781234567890" + // then md5(...) must equal "a1a7461d92e5194c5cae3182b5b24de1". + it("matches the docs' worked example (regression guard for the sign)", () => { + expect(md5Hex("2015063000000001apple654781234567890")).toBe( + "a1a7461d92e5194c5cae3182b5b24de1" + ); + }); +}); diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts index 42ae568..1adb83d 100644 --- a/src/__tests__/settings.test.ts +++ b/src/__tests__/settings.test.ts @@ -3,6 +3,8 @@ import { DEFAULT_SETTINGS, normalizeSettings, toProviderConfig, + cacheModel, + BAIDU_CACHE_MODEL, isConfigured, matchPreset, applyProviderPreset, @@ -86,37 +88,61 @@ describe("normalizeSettings", () => { }); describe("provider presets", () => { - it("every preset has a usable baseUrl and model", () => { + it("every OpenAI-compatible preset has a usable baseUrl and model", () => { for (const p of PROVIDER_PRESETS) { expect(p.id.length).toBeGreaterThan(0); - expect(p.baseUrl).toMatch(/^https?:\/\//); - expect(p.model.length).toBeGreaterThan(0); + if ((p.kind ?? "openai") === "openai") { + expect(p.baseUrl).toMatch(/^https?:\/\//); + expect(p.model.length).toBeGreaterThan(0); + } } }); + it("the Baidu preset carries kind:'baidu' and leaves baseUrl/model blank", () => { + // The Baidu preset repurposes baseUrl for the APP ID (user-supplied) and + // ignores model entirely — both must be blank so applying it doesn't + // clobber existing user input. + const baidu = PROVIDER_PRESETS.find((p) => p.id === "baidu"); + expect(baidu).toBeDefined(); + expect(baidu!.kind).toBe("baidu"); + expect(baidu!.baseUrl).toBe(""); + expect(baidu!.model).toBe(""); + }); + it("preset ids are unique", () => { const ids = PROVIDER_PRESETS.map((p) => p.id); expect(new Set(ids).size).toBe(ids.length); }); it("matches a preset by base URL, ignoring trailing slashes and case", () => { - expect(matchPreset("https://api.deepseek.com")?.id).toBe("deepseek"); - expect(matchPreset("https://api.deepseek.com/")?.id).toBe("deepseek"); - expect(matchPreset("HTTPS://API.DEEPSEEK.COM")?.id).toBe("deepseek"); - expect(matchPreset("https://api.openai.com/v1")?.id).toBe("openai"); + const s = (baseUrl: string) => normalizeSettings({ baseUrl }); + expect(matchPreset(s("https://api.deepseek.com"))?.id).toBe("deepseek"); + expect(matchPreset(s("https://api.deepseek.com/"))?.id).toBe("deepseek"); + expect(matchPreset(s("HTTPS://API.DEEPSEEK.COM"))?.id).toBe("deepseek"); + expect(matchPreset(s("https://api.openai.com/v1"))?.id).toBe("openai"); }); it("returns null for unknown endpoints (custom)", () => { - expect(matchPreset("https://my-proxy.example.com/v1")).toBeNull(); - expect(matchPreset("")).toBeNull(); + expect(matchPreset(normalizeSettings({ baseUrl: "https://my-proxy.example.com/v1" }))).toBeNull(); + // An empty baseUrl falls back to the DeepSeek default and thus matches. + expect(matchPreset(normalizeSettings({ baseUrl: "" }))?.id).toBe("deepseek"); }); it("the DeepSeek preset matches the shipped defaults", () => { - const p = matchPreset(DEFAULT_SETTINGS.baseUrl); + const p = matchPreset(DEFAULT_SETTINGS); expect(p?.id).toBe("deepseek"); expect(p?.model).toBe(DEFAULT_SETTINGS.model); }); + it("identifies the Baidu preset by kind, ignoring the base URL contents", () => { + // For providerKind:"baidu" the base URL carries an APP ID, not a service + // origin, so we must match by kind regardless of what URL is stored. + const s = normalizeSettings({ providerKind: "baidu", baseUrl: "20150630000000001" }); + const matched = matchPreset(s); + expect(matched?.id).toBe("baidu"); + expect(matched?.kind).toBe("baidu"); + }); + it("every preset declares a full, in-range Advanced tuning block", () => { for (const p of PROVIDER_PRESETS) { expect(p.advanced).toBeDefined(); @@ -130,7 +156,6 @@ describe("provider presets", () => { expect(adv.maxSegmentsPerBatch).toBeGreaterThanOrEqual(1); } }); - it("the DeepSeek preset's Advanced tuning equals the shipped defaults", () => { const ds = PROVIDER_PRESETS.find((p) => p.id === "deepseek")!; expect(ds.advanced).toMatchObject({ @@ -189,6 +214,29 @@ describe("applyProviderPreset", () => { const bad = { id: "x", label: "x", baseUrl: "https://x", model: "m", advanced: { concurrency: 999 } }; expect(applyProviderPreset(base, bad).concurrency).toBe(16); // clamped to max }); + + it("switching to the Baidu preset flips providerKind and blanks baseUrl/model", () => { + const baidu = PROVIDER_PRESETS.find((p) => p.id === "baidu")!; + const s = applyProviderPreset(base, baidu); + expect(s.providerKind).toBe("baidu"); + // Preset carries no baseUrl/model — user fills in APP ID afterwards. + expect(s.baseUrl).toBe(""); + expect(s.model).toBe(""); + // Applied advanced tuning (Baidu standard-tier QPS=1 shape). + expect(s.concurrency).toBe(1); + expect(s.minIntervalMs).toBeGreaterThan(0); + }); + + it("switching from Baidu back to an OpenAI preset restores providerKind + defaults", () => { + const baidu = PROVIDER_PRESETS.find((p) => p.id === "baidu")!; + const deepseek = PROVIDER_PRESETS.find((p) => p.id === "deepseek")!; + const s = applyProviderPreset(applyProviderPreset(base, baidu), deepseek); + expect(s.providerKind).toBe("openai"); + expect(s.baseUrl).toBe(deepseek.baseUrl); + expect(s.model).toBe(deepseek.model); + // Deepseek's advanced tuning applied, not Baidu's residual QPS=1. + expect(s.concurrency).toBe(10); + }); }); describe("providerConfigSignature", () => { @@ -238,4 +286,56 @@ describe("toProviderConfig / isConfigured", () => { expect(isConfigured(normalizeSettings({ apiKey: " " }))).toBe(false); expect(isConfigured(normalizeSettings({ apiKey: "sk-1" }))).toBe(true); }); + + it("Baidu also requires a non-empty baseUrl (the APP ID)", () => { + // APP ID lives in the baseUrl field for Baidu — key alone isn't enough. + expect(isConfigured(normalizeSettings({ providerKind: "baidu", apiKey: "secret", baseUrl: "" }))).toBe( + false + ); + expect(isConfigured(normalizeSettings({ providerKind: "baidu", apiKey: "secret", baseUrl: "20150001" }))).toBe( + true + ); + }); +}); + +describe("cacheModel", () => { + it("returns the model name for OpenAI-compatible settings", () => { + const s = normalizeSettings({ model: "gpt-4o-mini" }); + expect(cacheModel(s)).toBe("gpt-4o-mini"); + }); + + it("returns a stable constant for Baidu regardless of the ignored model input", () => { + // The Model field is displayed but not used on the wire for Baidu, so + // whatever the user types there must NOT drift the cache key. + const a = normalizeSettings({ providerKind: "baidu", apiKey: "s", baseUrl: "APPID", model: "" }); + const b = normalizeSettings({ providerKind: "baidu", apiKey: "s", baseUrl: "APPID", model: "junk" }); + expect(cacheModel(a)).toBe(BAIDU_CACHE_MODEL); + expect(cacheModel(b)).toBe(BAIDU_CACHE_MODEL); + expect(cacheModel(a)).toBe(cacheModel(b)); + }); +}); + +describe("providerKind persistence", () => { + it("defaults to 'openai' when absent (backward-compat with pre-Baidu data.json)", () => { + expect(normalizeSettings({}).providerKind).toBe("openai"); + expect(normalizeSettings({ apiKey: "sk" }).providerKind).toBe("openai"); + }); + + it("only accepts known kinds; unknown values fall back to the default", () => { + expect(normalizeSettings({ providerKind: "baidu" }).providerKind).toBe("baidu"); + expect(normalizeSettings({ providerKind: "openai" }).providerKind).toBe("openai"); + expect(normalizeSettings({ providerKind: "gemini" as never }).providerKind).toBe("openai"); + }); + + it("preserves an empty baseUrl/model for the Baidu kind (they carry APP ID / are unused)", () => { + const s = normalizeSettings({ providerKind: "baidu", baseUrl: "", model: "" }); + expect(s.baseUrl).toBe(""); + expect(s.model).toBe(""); + }); + + it("still falls back to the DeepSeek default for OpenAI kind with a blank baseUrl", () => { + const s = normalizeSettings({ providerKind: "openai", baseUrl: "", model: "" }); + expect(s.baseUrl).toBe(DEFAULT_SETTINGS.baseUrl); + expect(s.model).toBe(DEFAULT_SETTINGS.model); + }); }); diff --git a/src/settings.ts b/src/settings.ts index f5f69f6..3f82e12 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,6 +7,27 @@ import { ProviderConfig } from "./translator/provider"; export type DisplayMode = "bilingual" | "translation-only"; +/** + * Which translation backend to talk to. + * - "openai": any OpenAI-compatible `/chat/completions` endpoint (DeepSeek / + * OpenAI / SiliconFlow / Ollama / any custom URL). `baseUrl` is the API + * origin, `model` picks the model. + * - "baidu": Baidu's general translate API (documented at fanyi-api.baidu.com). + * `baseUrl` carries the APP ID (repurposed), `apiKey` carries the secret; + * `model` is not used by the wire protocol at all — cache identity is + * derived from a stable constant via {@link cacheModel} so the field's + * content never invalidates cached translations. + */ +export type ProviderKind = "openai" | "baidu"; + +/** + * Stable cache-identity for the Baidu general translate API. The actual model + * name in settings is ignored for Baidu (there is no such wire field), so the + * cache key must not depend on whatever text happens to be sitting in the + * "Model" input — this constant takes its place. + */ +export const BAIDU_CACHE_MODEL = "baidu-general"; + /** Where the in-view floating button (FAB) is shown. */ export type FabVisibility = "always" | "mobile" | "never"; @@ -30,14 +51,19 @@ export type ProviderPresetAdvanced = Partial< >; /** - * OpenAI-compatible service presets. Picking one pre-fills baseUrl/model and — - * since each service rate-limits differently — its recommended Advanced tuning - * (see {@link applyProviderPreset}). Any endpoint speaking `/chat/completions` - * still works via the Custom option (which never touches Advanced). + * OpenAI-compatible service presets, plus the Baidu general translate API. + * Picking one pre-fills baseUrl/model and — since each service rate-limits + * differently — its recommended Advanced tuning (see {@link applyProviderPreset}). + * Any OpenAI-compatible endpoint still works via the Custom option (which + * never touches Advanced). The Baidu preset switches `providerKind` to + * "baidu": the wire format differs entirely, and for that kind `baseUrl` + * holds the APP ID and `apiKey` holds the secret (see ProviderKind). */ export interface ProviderPreset { id: string; label: string; + /** Which wire protocol this preset selects. Defaults to "openai". */ + kind?: ProviderKind; baseUrl: string; model: string; /** @@ -86,24 +112,58 @@ export const PROVIDER_PRESETS: ReadonlyArray = [ // local model also miscounts <<>> markers more, so keep batches small. advanced: { concurrency: 2, minIntervalMs: 0, maxRetries: 2, batchCharBudget: 2000, maxSegmentsPerBatch: 6 }, }, + { + id: "baidu", + label: "Baidu API", + kind: "baidu", + // For Baidu, `baseUrl` is repurposed to hold the APP ID (per user request: + // "API key" -> secret, "Base URL" -> APP ID). Leave it empty in the preset + // so switching in doesn't clobber a previously configured APP ID. The wire + // endpoint is hard-coded inside the Baidu provider. + baseUrl: "", + // Baidu has no "model" concept; the field is displayed but ignored on the + // wire. Blank in the preset so switching in doesn't stamp a misleading name. + model: "", + // Standard-tier Baidu is QPS=1 (higher tiers are 10 / 100). Keep the fleet + // small and space starts by ~1.1s so the standard tier passes untuned; + // small batches also keep per-segment line accounting reliable. + advanced: { concurrency: 1, minIntervalMs: 1100, maxRetries: 3, batchCharBudget: 2000, maxSegmentsPerBatch: 8 }, + }, ]; function normalizeBaseUrl(url: string): string { return url.trim().replace(/\/+$/, "").toLowerCase(); } -/** Find the preset matching a base URL (model may be customized freely). */ -export function matchPreset(baseUrl: string): ProviderPreset | null { - const norm = normalizeBaseUrl(baseUrl); - return PROVIDER_PRESETS.find((p) => normalizeBaseUrl(p.baseUrl) === norm) ?? null; +/** + * Find the preset matching the current settings. + * + * Kind-first, then base URL: + * - `providerKind: "baidu"` is a hard signal — the base URL is a user-supplied + * APP ID, not a service origin, so we identify the preset by kind alone. + * Any preset carrying `kind: "baidu"` wins; falling back to the base-URL + * match would incorrectly land on Custom (or on a URL-shaped APP ID). + * - Otherwise we match an OpenAI-compatible preset by normalized base URL + * (trailing-slash / case insensitive; model may be customized freely). + */ +export function matchPreset(settings: InterlinearSettings): ProviderPreset | null { + if (settings.providerKind === "baidu") { + return PROVIDER_PRESETS.find((p) => p.kind === "baidu") ?? null; + } + const norm = normalizeBaseUrl(settings.baseUrl); + return ( + PROVIDER_PRESETS.find((p) => (p.kind ?? "openai") === "openai" && normalizeBaseUrl(p.baseUrl) === norm) ?? + null + ); } /** * Settings produced by selecting a service preset: always sets baseUrl + model, - * and overwrites the current values with the preset's recommended Advanced - * tuning (only the fields it declares; the rest are kept). The result is run - * through {@link normalizeSettings} so every value stays clamped/valid. Pure + - * testable — the UI persists the returned object. + * switches providerKind to the preset's kind ("openai" by default), and + * overwrites the current values with the preset's recommended Advanced tuning + * (only the fields it declares; the rest are kept). The result is run through + * {@link normalizeSettings} so every value stays clamped/valid. Pure + testable + * — the UI persists the returned object. */ export function applyProviderPreset( current: InterlinearSettings, @@ -111,6 +171,7 @@ export function applyProviderPreset( ): InterlinearSettings { return normalizeSettings({ ...current, + providerKind: preset.kind ?? "openai", baseUrl: preset.baseUrl, model: preset.model, ...preset.advanced, @@ -118,6 +179,12 @@ export function applyProviderPreset( } export interface InterlinearSettings { + /** + * Which backend protocol to speak. Also decides how baseUrl/model are used — + * see {@link ProviderKind}. Defaults to "openai" for backward compatibility + * with settings saved before this field existed. + */ + providerKind: ProviderKind; /** BYOK — stored only in local data.json; never logged, never committed. */ apiKey: string; baseUrl: string; @@ -145,6 +212,7 @@ export interface InterlinearSettings { } export const DEFAULT_SETTINGS: InterlinearSettings = { + providerKind: "openai", apiKey: "", baseUrl: "https://api.deepseek.com", model: "deepseek-v4-flash", @@ -179,6 +247,7 @@ const TRANSLATION_STYLE_VALUES: ReadonlyArray = [ "dashed", "mask", ]; +const PROVIDER_KINDS: ReadonlyArray = ["openai", "baidu"]; function oneOf(value: unknown, allowed: ReadonlyArray, fallback: T): T { return allowed.includes(value as T) ? (value as T) : fallback; @@ -201,10 +270,25 @@ function clampInt(value: unknown, min: number, max: number, fallback: number): n export function normalizeSettings(raw: unknown): InterlinearSettings { const r = (raw && typeof raw === "object" ? raw : {}) as Partial; const merged = { ...DEFAULT_SETTINGS, ...r }; + const providerKind = oneOf(merged.providerKind, PROVIDER_KINDS, DEFAULT_SETTINGS.providerKind); + // For OpenAI-compatible endpoints, an empty baseUrl/model is meaningless, so + // fall back to the DeepSeek defaults. For Baidu, `baseUrl` carries the APP ID + // and `model` is unused — those defaults would be actively WRONG there, so + // preserve whatever the user has entered (even if empty; isConfigured will + // then simply gate translation until they fill it in). + const baseUrl = + providerKind === "baidu" + ? typeof merged.baseUrl === "string" ? merged.baseUrl.trim() : "" + : nonEmptyOr(merged.baseUrl, DEFAULT_SETTINGS.baseUrl); + const model = + providerKind === "baidu" + ? typeof merged.model === "string" ? merged.model.trim() : "" + : nonEmptyOr(merged.model, DEFAULT_SETTINGS.model); return { + providerKind, apiKey: typeof merged.apiKey === "string" ? merged.apiKey : "", - baseUrl: nonEmptyOr(merged.baseUrl, DEFAULT_SETTINGS.baseUrl), - model: nonEmptyOr(merged.model, DEFAULT_SETTINGS.model), + baseUrl, + model, defaultDisplayMode: merged.defaultDisplayMode === "translation-only" ? "translation-only" : "bilingual", targetLang: nonEmptyOr(merged.targetLang, DEFAULT_SETTINGS.targetLang), @@ -225,6 +309,16 @@ export function normalizeSettings(raw: unknown): InterlinearSettings { }; } +/** + * Cache-identity for the current settings. Substitutes a stable constant for + * "model" on backends that don't have a model field (Baidu), so the cache key + * never depends on whatever text is sitting in the ignored input. OpenAI-family + * providers use the real model name so switching models still invalidates. + */ +export function cacheModel(s: InterlinearSettings): string { + return s.providerKind === "baidu" ? BAIDU_CACHE_MODEL : s.model; +} + /** Project the provider-relevant subset for the translation backend. */ export function toProviderConfig(s: InterlinearSettings): ProviderConfig { return { @@ -236,9 +330,15 @@ export function toProviderConfig(s: InterlinearSettings): ProviderConfig { }; } -/** A translation can only run once an API key has been supplied (BYOK). */ +/** + * A translation can only run once BYOK credentials have been supplied. Baidu + * additionally requires an APP ID (which we store in `baseUrl`), so both + * fields must be non-empty for that kind. + */ export function isConfigured(s: InterlinearSettings): boolean { - return s.apiKey.trim().length > 0; + if (s.apiKey.trim().length === 0) return false; + if (s.providerKind === "baidu" && s.baseUrl.trim().length === 0) return false; + return true; } /** @@ -247,8 +347,10 @@ export function isConfigured(s: InterlinearSettings): boolean { * may differ, so the controller drops its per-note "failed/skip" set when this * changes — whether edited in settings or synced in externally. Rate/batch knobs * (concurrency, retries, …) are intentionally excluded: they tune delivery, not - * the request's success criteria or result identity. + * the request's success criteria or result identity. Includes providerKind + * because switching backend semantics changes everything even if the surface + * fields happen to match. */ export function providerConfigSignature(s: InterlinearSettings): string { - return JSON.stringify(toProviderConfig(s)); + return JSON.stringify({ kind: s.providerKind, ...toProviderConfig(s) }); } diff --git a/src/translator/baidu.ts b/src/translator/baidu.ts new file mode 100644 index 0000000..922f46d --- /dev/null +++ b/src/translator/baidu.ts @@ -0,0 +1,324 @@ +/** + * Baidu General Translate — PURE parts + provider. + * + * Wire endpoint: `POST https://fanyi-api.baidu.com/api/trans/vip/translate` + * with `application/x-www-form-urlencoded`. Different protocol from the + * OpenAI-compatible endpoints, so this lives alongside deepseek.ts as a peer. + * + * How the reused {@link ProviderConfig} maps to Baidu: + * - config.apiKey = Baidu API secret (密钥) + * - config.baseUrl = Baidu APP ID (appid) — repurposed field + * - config.model = IGNORED (Baidu has no such wire concept) + * - config.customInstructions = IGNORED (no system prompt) + * - config.targetLang = BCP-47 code, mapped to Baidu's codes + * + * Batching contract: N source segments are joined with `\n` into `q`; the API + * returns a `trans_result` entry per line. Each source segment may itself hold + * newlines, so we track a per-segment "line count" and regroup accordingly. A + * mismatch throws {@link SegmentCountMismatchError}, letting the provider fall + * back to per-segment translation (mirroring the DeepSeek provider's design). + */ +import { + ProviderConfig, + HttpClient, + HttpRequestSpec, + HttpResponseLike, + TranslationProvider, + AuthError, + RateLimitError, + MalformedResponseError, + SegmentCountMismatchError, + TranslationError, +} from "./provider"; +import { md5Hex } from "./md5"; + +export const BAIDU_ENDPOINT = "https://fanyi-api.baidu.com/api/trans/vip/translate"; + +/** + * BCP-47 target language -> Baidu language code. + * Baidu uses its own codes (e.g. `jp` for Japanese, `fra` for French). If a + * code isn't listed we pass the base subtag through — many codes are shared + * (en, de, it, ru, pl, nl, th, ...), and unknown-target errors then surface + * as Baidu's own 58001 which we map to a MalformedResponseError. + */ +const BCP47_TO_BAIDU: Record = { + // Chinese + "zh": "zh", + "zh-cn": "zh", + "zh-hans": "zh", + "zh-tw": "cht", + "zh-hk": "cht", + "zh-hant": "cht", + // Japanese / Korean + "ja": "jp", + "ko": "kor", + // European (Baidu's 3-letter shorthand for a few) + "fr": "fra", + "es": "spa", + "ar": "ara", + "bg": "bul", + "et": "est", + "da": "dan", + "fi": "fin", + "ro": "rom", + "sl": "slo", + "sv": "swe", + // Portuguese / Vietnamese + "pt": "pt", + "pt-br": "pt", + "pt-pt": "pt", + "vi": "vie", +}; + +/** Map a BCP-47 code (case-insensitive) to Baidu's language code, best-effort. */ +export function toBaiduLangCode(bcp47: string): string { + const norm = bcp47.trim().toLowerCase(); + if (norm in BCP47_TO_BAIDU) return BCP47_TO_BAIDU[norm]; + const base = norm.split(/[-_]/)[0]; + if (base in BCP47_TO_BAIDU) return BCP47_TO_BAIDU[base]; + return base; +} + +/** + * Build the sign — `md5(appid + q + salt + secret)`. `q` here MUST be the raw + * UTF-8 text, NOT URL-encoded — see docs §54001 troubleshooting: pre-encoding + * `q` before signing is the single most common cause of 54001 sign errors. + */ +export function buildBaiduSign(appid: string, q: string, salt: string, secret: string): string { + return md5Hex(appid + q + salt + secret); +} + +/** Salt: a numeric-ish string. Any letters/digits string works per the docs. */ +export function makeSalt(now: () => number = Date.now, rand: () => number = Math.random): string { + return `${now()}${Math.floor(rand() * 1e6)}`; +} + +/** + * URL-encode a form value (application/x-www-form-urlencoded uses `+` for + * space; encodeURIComponent's `%20` is a legal but non-canonical variant). + */ +function formEncode(value: string): string { + return encodeURIComponent(value).replace(/%20/g, "+"); +} + +export interface BaiduRequestOptions { + /** Injected for tests; a fresh random salt is generated per request otherwise. */ + salt?: string; +} + +/** + * Build the Baidu translate POST request. `q` may contain `\n` — each line + * yields a separate `trans_result` entry in the response. + */ +export function buildBaiduRequest( + q: string, + cfg: ProviderConfig, + opts: BaiduRequestOptions = {} +): HttpRequestSpec { + const appid = cfg.baseUrl.trim(); + const secret = cfg.apiKey; + const to = toBaiduLangCode(cfg.targetLang); + const salt = opts.salt ?? makeSalt(); + const sign = buildBaiduSign(appid, q, salt, secret); + + // Order matches the docs example. All values are URL-encoded ONLY here; the + // sign was computed above on the raw `q` per the 54001 troubleshooting rules. + const form: Array<[string, string]> = [ + ["q", q], + ["from", "auto"], + ["to", to], + ["appid", appid], + ["salt", salt], + ["sign", sign], + ]; + const body = form.map(([k, v]) => `${k}=${formEncode(v)}`).join("&"); + return { + url: BAIDU_ENDPOINT, + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }; +} + +function splitLines(text: string): string[] { + return text.split(/\r?\n/); +} + +/** + * Pack multi-line source segments into one `q` and record how many lines each + * contributed (empty lines count too — Baidu returns one trans_result entry + * per input line, so the regroup must include them). Returns the joined `q` + * plus the per-segment line counts for {@link unpackBaiduTransResult}. + */ +export function packBaiduBatch(segments: string[]): { q: string; lineCounts: number[] } { + const lineCounts: number[] = []; + const allLines: string[] = []; + for (const s of segments) { + const lines = splitLines(s); + lineCounts.push(lines.length); + allLines.push(...lines); + } + return { q: allLines.join("\n"), lineCounts }; +} + +/** + * Regroup a flat `trans_result` `dst` stream back into per-segment translations + * using the recorded per-segment line counts. Throws + * {@link SegmentCountMismatchError} when the totals disagree — the caller + * (BaiduProvider) then falls back to one request per segment. + */ +export function unpackBaiduTransResult(dsts: string[], lineCounts: number[]): string[] { + const total = lineCounts.reduce((a, b) => a + b, 0); + if (dsts.length !== total) { + throw new SegmentCountMismatchError(total, dsts.length); + } + const out: string[] = []; + let pos = 0; + for (const n of lineCounts) { + out.push(dsts.slice(pos, pos + n).join("\n")); + pos += n; + } + return out; +} + +// Baidu error_code -> typed TranslationError. The rate limiter reads `retryable` +// off the returned error and honors `retryAfterMs` when present; here Baidu +// gives us no Retry-After hint, so retryable RateLimitErrors just use the +// pool's exponential backoff. +function mapBaiduErrorCode(code: string, msg: string): TranslationError { + switch (code) { + // Auth / configuration — user must fix credentials, endpoint config, or IP. + case "52003": // unauthorized user (bad appid / service off) + case "54001": // sign error (bad secret or bad request construction) + case "54004": // insufficient balance + case "58000": // illegal client IP + case "58002": // service currently off + case "58003": // IP banned + case "90107": // auth not passed + return new AuthError(`Baidu ${code}: ${msg || "authentication/config error"}`); + + // Retryable transient errors. + case "52001": // request timeout + case "52002": // system error ("请重试") + return new RateLimitError(`Baidu ${code}: ${msg || "transient error, retry"}`); + case "54003": // rate limited (QPS) + case "54005": // long-query rate limited (3s cooldown) + return new RateLimitError(`Baidu ${code}: ${msg || "rate limited"}`); + + // Bad request / content — not worth retrying identically. + case "54000": // missing required param — never expected here + case "58001": // target language not supported for tier + case "20003": // safety risk (blocked content) + default: + return new MalformedResponseError(`Baidu ${code}: ${msg || "unexpected response"}`); + } +} + +interface BaiduSuccessBody { + trans_result?: Array<{ src?: unknown; dst?: unknown }>; + error_code?: unknown; + error_msg?: unknown; +} + +// The docs say error_code appears only on error, but 52000 = "success" also +// exists. Treat "0" and "52000" as success — anything else present is an error. +function isSuccessCode(code: unknown): boolean { + if (code === undefined) return true; + const s = String(code); + return s === "0" || s === "52000"; +} + +/** + * Parse a Baidu translate response into a flat list of translated lines (each + * corresponding to one input line of `q`). The caller regroups these into per- + * segment translations using {@link unpackBaiduTransResult}. + */ +export function parseBaiduResponse(res: HttpResponseLike): string[] { + if (res.status < 200 || res.status >= 300) { + throw new MalformedResponseError(`HTTP ${res.status}`); + } + let payload: unknown = res.json; + if (payload === undefined || payload === null) { + try { + payload = JSON.parse(res.text); + } catch { + throw new MalformedResponseError("Response body is not valid JSON."); + } + } + if (!payload || typeof payload !== "object") { + throw new MalformedResponseError("Response body is not a JSON object."); + } + const body = payload as BaiduSuccessBody; + if (!isSuccessCode(body.error_code)) { + const code = String(body.error_code); + const msg = typeof body.error_msg === "string" ? body.error_msg : ""; + throw mapBaiduErrorCode(code, msg); + } + if (!Array.isArray(body.trans_result)) { + throw new MalformedResponseError("Missing trans_result array."); + } + const out: string[] = []; + for (const entry of body.trans_result) { + const dst = entry && typeof entry === "object" ? (entry as { dst?: unknown }).dst : undefined; + if (typeof dst !== "string") { + throw new MalformedResponseError("A trans_result entry is missing its dst field."); + } + out.push(dst); + } + return out; +} + +export interface BaiduProviderDeps { + config: ProviderConfig; + /** Injected transport — `obsidianRequestUrlClient` in production, a fake in tests. */ + http: HttpClient; + /** Injected for tests; defaults to {@link makeSalt}. */ + salt?: () => string; +} + +/** + * Baidu translate provider. Composes the pure request builder + parser with an + * injected {@link HttpClient}. Presents the same {@link TranslationProvider} + * surface as the DeepSeek provider, so the UI/controller flows are unchanged. + * No `obsidian`, no DOM — the real `requestUrl` transport is wired by callers. + */ +export class BaiduProvider implements TranslationProvider { + private readonly config: ProviderConfig; + private readonly http: HttpClient; + private readonly saltFn: () => string; + + constructor(deps: BaiduProviderDeps) { + this.config = deps.config; + this.http = deps.http; + this.saltFn = deps.salt ?? makeSalt; + } + + async translate(segments: string[]): Promise { + if (segments.length === 0) return []; + try { + const { q, lineCounts } = packBaiduBatch(segments); + const res = await this.http(buildBaiduRequest(q, this.config, { salt: this.saltFn() })); + const dsts = parseBaiduResponse(res); + return unpackBaiduTransResult(dsts, lineCounts); + } catch (err) { + // Only a broken batch contract triggers the per-segment fallback; auth/ + // rate-limit/malformed errors propagate to the pool. + if (err instanceof SegmentCountMismatchError && segments.length > 1) { + return this.translateOneByOne(segments); + } + throw err; + } + } + + /** Per-segment fallback: one request each, so every result maps 1:1. */ + private async translateOneByOne(segments: string[]): Promise { + const out: string[] = []; + for (const seg of segments) { + const { q, lineCounts } = packBaiduBatch([seg]); + const res = await this.http(buildBaiduRequest(q, this.config, { salt: this.saltFn() })); + const dsts = parseBaiduResponse(res); + out.push(unpackBaiduTransResult(dsts, lineCounts)[0]); + } + return out; + } +} diff --git a/src/translator/factory.ts b/src/translator/factory.ts new file mode 100644 index 0000000..5b05af1 --- /dev/null +++ b/src/translator/factory.ts @@ -0,0 +1,33 @@ +/** + * Provider factory — picks the {@link TranslationProvider} implementation for + * the current settings. Keeps `TranslationProvider` decisions out of the UI / + * controller: they just call `createProvider(settings, http)` and use the + * result. Adding a new backend later (Gemini/Claude/whatever) means adding a + * `ProviderKind` case here — the call sites stay identical. + * + * Pure: no `obsidian`, no DOM, no network — the transport is passed in. + */ +import { InterlinearSettings, toProviderConfig } from "../settings"; +import { HttpClient, TranslationProvider } from "./provider"; +import { DeepSeekProvider } from "./deepseek"; +import { BaiduProvider } from "./baidu"; + +/** + * Build the provider for the current settings. The `apiKey`/`baseUrl`/`model` + * fields on {@link ProviderConfig} carry backend-specific meaning — see + * {@link ProviderKind} — but by this point the setting-time normalization + * already stored them in the right shape. + */ +export function createProvider( + settings: InterlinearSettings, + http: HttpClient +): TranslationProvider { + const config = toProviderConfig(settings); + switch (settings.providerKind) { + case "baidu": + return new BaiduProvider({ config, http }); + case "openai": + default: + return new DeepSeekProvider({ config, http }); + } +} diff --git a/src/translator/md5.ts b/src/translator/md5.ts new file mode 100644 index 0000000..73dc88c --- /dev/null +++ b/src/translator/md5.ts @@ -0,0 +1,124 @@ +/** + * MD5 (RFC 1321) — pure, dependency-free, UTF-8 aware. + * + * Needed for the Baidu translate `sign` parameter: `md5(appid + q + salt + secret)` + * as a 32-char lowercase hex string. Node's `crypto` is not available in the + * Obsidian renderer, and `SubtleCrypto` intentionally omits MD5 — so we ship a + * small implementation instead of pulling in a dependency. + * + * SECURITY: MD5 is used ONLY for Baidu's non-cryptographic API signature. + * Never use this hash for anything security-sensitive. + */ + +// Per-round left-rotation amounts (RFC 1321 §3.4). +const S: ReadonlyArray = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +]; + +// Constants K[i] = floor(2^32 * abs(sin(i + 1))) (RFC 1321 §3.4, Table T). +const K: ReadonlyArray = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, + 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, + 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, + 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, + 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, + 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, + 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, + 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, + 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, + 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, + 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, + 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, + 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, + 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, + 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, +]; + +/** Rotate `x` left by `n` bits within 32-bit unsigned semantics. */ +function rotl(x: number, n: number): number { + return ((x << n) | (x >>> (32 - n))) >>> 0; +} + +/** UTF-8-encode `input` — matches Baidu's "UTF-8 encoded q" contract. */ +function utf8Bytes(input: string): Uint8Array { + // TextEncoder is available in Electron (Obsidian renderer) and Node 11+. + return new TextEncoder().encode(input); +} + +/** MD5 hash of a byte buffer, returned as 32-char lowercase hex. */ +export function md5Bytes(bytes: Uint8Array): string { + const bitLength = bytes.length * 8; + // Padding: 0x80, then zero bytes to fill (bytes.length + 9) up to a 64-byte + // boundary, then 8 bytes of original bit length (little-endian). + const paddedLen = ((bytes.length + 9 + 63) >>> 6) << 6; + const buffer = new Uint8Array(paddedLen); + buffer.set(bytes); + buffer[bytes.length] = 0x80; + const view = new DataView(buffer.buffer); + // JS numbers are safe integers up to 2^53, so byte lengths up to ~1 PB fit + // in the low 32-bits of the bit-length. High word stays 0 in practice. + view.setUint32(paddedLen - 8, bitLength >>> 0, true); + view.setUint32(paddedLen - 4, Math.floor(bitLength / 0x100000000) >>> 0, true); + + let a0 = 0x67452301; + let b0 = 0xefcdab89; + let c0 = 0x98badcfe; + let d0 = 0x10325476; + + const M = new Array(16); + for (let block = 0; block < paddedLen; block += 64) { + for (let i = 0; i < 16; i++) { + M[i] = view.getUint32(block + i * 4, true); + } + + let A = a0, B = b0, C = c0, D = d0; + for (let i = 0; i < 64; i++) { + let F: number; + let g: number; + if (i < 16) { + F = (B & C) | (~B & D); + g = i; + } else if (i < 32) { + F = (D & B) | (~D & C); + g = (5 * i + 1) & 15; + } else if (i < 48) { + F = B ^ C ^ D; + g = (3 * i + 5) & 15; + } else { + F = C ^ (B | ~D); + g = (7 * i) & 15; + } + const sum = (A + F + K[i] + M[g]) >>> 0; + A = D; + D = C; + C = B; + B = (B + rotl(sum, S[i])) >>> 0; + } + + a0 = (a0 + A) >>> 0; + b0 = (b0 + B) >>> 0; + c0 = (c0 + C) >>> 0; + d0 = (d0 + D) >>> 0; + } + + return wordToLeHex(a0) + wordToLeHex(b0) + wordToLeHex(c0) + wordToLeHex(d0); +} + +/** Encode a 32-bit word as 8 lowercase hex chars, LEAST-significant byte first. */ +function wordToLeHex(word: number): string { + let out = ""; + for (let i = 0; i < 4; i++) { + const byte = (word >>> (i * 8)) & 0xff; + out += (byte < 16 ? "0" : "") + byte.toString(16); + } + return out; +} + +/** MD5 of a JavaScript string, UTF-8 encoded, as 32-char lowercase hex. */ +export function md5Hex(input: string): string { + return md5Bytes(utf8Bytes(input)); +} diff --git a/src/ui/settingsTab.ts b/src/ui/settingsTab.ts index 47ef4fe..2598f14 100644 --- a/src/ui/settingsTab.ts +++ b/src/ui/settingsTab.ts @@ -3,13 +3,12 @@ import type InterlinearPlugin from "../main"; import { applyProviderPreset, matchPreset, - toProviderConfig, isConfigured, PROVIDER_PRESETS, TRANSLATION_STYLES, } from "../settings"; import type { DisplayMode, FabVisibility, TranslationStyle } from "../settings"; -import { DeepSeekProvider } from "../translator/deepseek"; +import { createProvider } from "../translator/factory"; import { AuthError, RateLimitError } from "../translator/provider"; import { obsidianRequestUrlClient } from "../translator/requestUrlClient"; @@ -72,13 +71,15 @@ export class InterlinearSettingTab extends PluginSettingTab { private renderServiceSection(containerEl: HTMLElement): void { new Setting(containerEl).setName("Translation service").setHeading(); - const matched = matchPreset(this.plugin.settings.baseUrl); + const settings = this.plugin.settings; + const matched = matchPreset(settings); const showCustomProvider = this.customProviderMode || matched === null; + const isBaidu = settings.providerKind === "baidu"; new Setting(containerEl) .setName("Service preset") .setDesc( - "Pre-fills the endpoint, model, and recommended rate/batch tuning for common OpenAI-compatible services (overwrites the Advanced values below). Any /chat/completions endpoint works via Custom, which leaves Advanced untouched." + "Pre-fills the endpoint, model, and recommended rate/batch tuning for common services (overwrites the Advanced values below). Custom leaves Advanced untouched and speaks the OpenAI-compatible /chat/completions protocol." ) .addDropdown((dropdown) => { for (const p of PROVIDER_PRESETS) dropdown.addOption(p.id, p.label); @@ -87,6 +88,11 @@ export class InterlinearSettingTab extends PluginSettingTab { dropdown.onChange(async (value) => { if (value === CUSTOM_PROVIDER) { this.customProviderMode = true; + // Custom means "any OpenAI-compatible endpoint" — flip kind back to + // openai in case we're coming from the Baidu preset (whose baseUrl + // holds an APP ID, not a URL). Then let the user edit the fields. + this.plugin.settings.providerKind = "openai"; + await this.plugin.saveSettings(); } else { this.customProviderMode = false; const preset = PROVIDER_PRESETS.find((p) => p.id === value); @@ -104,10 +110,14 @@ export class InterlinearSettingTab extends PluginSettingTab { new Setting(containerEl) .setName("API key") - .setDesc("BYOK — stored only in the local data.json; never uploaded, logged, or committed.") + .setDesc( + isBaidu + ? "Baidu API secret (密钥). BYOK — stored only in the local data.json; never uploaded, logged, or committed." + : "BYOK — stored only in the local data.json; never uploaded, logged, or committed." + ) .addText((text) => { text - .setPlaceholder("sk-...") + .setPlaceholder(isBaidu ? "Baidu secret key" : "sk-...") .setValue(this.plugin.settings.apiKey) .onChange(async (value) => { this.plugin.settings.apiKey = value.trim(); @@ -118,23 +128,42 @@ export class InterlinearSettingTab extends PluginSettingTab { new Setting(containerEl) .setName("Base URL") - .setDesc("Base address of the OpenAI-compatible endpoint.") + .setDesc( + isBaidu + ? "Baidu APP ID (appid). This field is repurposed for Baidu — the wire endpoint is fixed by the service." + : "Base address of the OpenAI-compatible endpoint." + ) .addText((text) => text - .setPlaceholder("https://api.deepseek.com") + .setPlaceholder(isBaidu ? "e.g. 2015063000000001" : "https://api.deepseek.com") .setValue(this.plugin.settings.baseUrl) .onChange(async (value) => { - this.plugin.settings.baseUrl = value.trim() || "https://api.deepseek.com"; + const trimmed = value.trim(); + // For Baidu, an empty baseUrl means "no APP ID yet" (isConfigured + // will gate translation). For OpenAI-compatible it falls back to + // the DeepSeek default so a blank field never breaks translation. + this.plugin.settings.baseUrl = isBaidu + ? trimmed + : trimmed || "https://api.deepseek.com"; await this.plugin.saveSettings(); }) ); - new Setting(containerEl).setName("Model").addText((text) => - text.setValue(this.plugin.settings.model).onChange(async (value) => { - this.plugin.settings.model = value.trim() || "deepseek-v4-flash"; - await this.plugin.saveSettings(); - }) - ); + new Setting(containerEl) + .setName("Model") + .setDesc( + isBaidu + ? "Not used by Baidu (the API has no model field). Anything typed here is ignored." + : "" + ) + .addText((text) => { + text.setValue(this.plugin.settings.model).onChange(async (value) => { + const trimmed = value.trim(); + this.plugin.settings.model = isBaidu ? trimmed : trimmed || "deepseek-v4-flash"; + await this.plugin.saveSettings(); + }); + text.inputEl.disabled = isBaidu; + }); new Setting(containerEl) .setName("Test connection") @@ -142,16 +171,17 @@ export class InterlinearSettingTab extends PluginSettingTab { .addButton((btn) => { btn.setButtonText("Test").onClick(async () => { if (!isConfigured(this.plugin.settings)) { - new Notice("Set an API key first (for local servers any non-empty value works)."); + new Notice( + this.plugin.settings.providerKind === "baidu" + ? "Set an APP ID (Base URL) and secret (API key) first." + : "Set an API key first (for local servers any non-empty value works)." + ); return; } btn.setDisabled(true); btn.setButtonText("Testing…"); try { - const provider = new DeepSeekProvider({ - config: toProviderConfig(this.plugin.settings), - http: obsidianRequestUrlClient, - }); + const provider = createProvider(this.plugin.settings, obsidianRequestUrlClient); const [sample] = await provider.translate(["Hello!"]); new Notice(`Connection OK — sample translation: ${sample.slice(0, 60)}`); } catch (err) { diff --git a/src/ui/translateButton.ts b/src/ui/translateButton.ts index 7ad10fc..ad58738 100644 --- a/src/ui/translateButton.ts +++ b/src/ui/translateButton.ts @@ -33,10 +33,10 @@ import { import { isLikelyTargetLanguage } from "../core/blockRules"; import { chunkByBudget, Segment } from "../core/segmentation"; import { runPool } from "../core/rateLimiter"; -import { DeepSeekProvider } from "../translator/deepseek"; +import { createProvider } from "../translator/factory"; import { HttpClient, AuthError } from "../translator/provider"; import { TranslationCache } from "../translator/cache"; -import { DisplayMode, InterlinearSettings, isConfigured, toProviderConfig } from "../settings"; +import { cacheModel, DisplayMode, InterlinearSettings, isConfigured } from "../settings"; const REVEAL_OFF_CLASS = "it-reveal-off"; // on container: hide translations, show originals const SYNC_DEBOUNCE_MS = 120; @@ -268,7 +268,11 @@ export class TranslationController { private ensureConfigured(): boolean { if (isConfigured(this.getSettings())) return true; - new Notice("Set your DeepSeek API key in Interlinear settings first."); + new Notice( + this.getSettings().providerKind === "baidu" + ? "Set your Baidu APP ID (Base URL) and secret (API key) in Interlinear settings first." + : "Set your API key in Interlinear settings first." + ); return false; } @@ -364,7 +368,8 @@ export class TranslationController { return; } - const { model, targetLang } = settings; + const { targetLang } = settings; + const model = cacheModel(settings); // Skip blocks already written in the target language (no request needed). const translatable = texts.filter((t) => !isLikelyTargetLanguage(t, targetLang)); if (translatable.length === 0) { @@ -381,7 +386,7 @@ export class TranslationController { try { const segments: Segment[] = misses.map((text, index) => ({ index, text })); const chunks = chunkByBudget(segments, settings.batchCharBudget, settings.maxSegmentsPerBatch); - const provider = new DeepSeekProvider({ config: toProviderConfig(settings), http: this.http }); + const provider = createProvider(settings, this.http); this.addProgressTotal(active.path, chunks.length); const tasks = chunks.map((chunk) => async () => { @@ -425,7 +430,8 @@ export class TranslationController { settings: InterlinearSettings, st: FileState ): Promise { - const { model, targetLang } = settings; + const { targetLang } = settings; + const model = cacheModel(settings); const ctx: InjectContext = { app: this.app, sourcePath: path, component: this.component }; const misses: Array<{ el: HTMLElement; text: string }> = []; @@ -445,7 +451,7 @@ export class TranslationController { const segments: Segment[] = misses.map((m, k) => ({ index: k, text: m.text })); const chunks = chunkByBudget(segments, settings.batchCharBudget, settings.maxSegmentsPerBatch); - const provider = new DeepSeekProvider({ config: toProviderConfig(settings), http: this.http }); + const provider = createProvider(settings, this.http); this.addProgressTotal(path, chunks.length); // Each task injects its blocks (and clears their spinners) as it completes.