diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1b3c4a45d3..d67a7105c4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1243,7 +1243,7 @@ export interface Config { bingEndpoint: string /** Deadline covering the request and complete response body, in milliseconds. */ timeoutMs: number - /** Maximum UTF-16 code units of submitted text per request. */ + /** Maximum UTF-16 code units per request; at least two to admit one supplementary-plane character. */ maxTextChars: number /** Maximum response body bytes before JSON parsing. */ maxResponseBytes: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c7b675a049..a7a009839c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1245,7 +1245,7 @@ export interface Config { bingEndpoint: string /** Deadline covering the request and complete response body, in milliseconds. */ timeoutMs: number - /** Maximum UTF-16 code units of submitted text per request. */ + /** Maximum UTF-16 code units per request; at least two to admit one supplementary-plane character. */ maxTextChars: number /** Maximum response body bytes before JSON parsing. */ maxResponseBytes: number diff --git a/docs/subsystems/translation.i18n.yaml b/docs/subsystems/translation.i18n.yaml index 5cffcb70b5..9d1b1b4e7a 100644 --- a/docs/subsystems/translation.i18n.yaml +++ b/docs/subsystems/translation.i18n.yaml @@ -9,5 +9,5 @@ en: 9d963e74f160b5ef zh: 99f5a7c187148743 /text-translation/failures-and-data-handling: - en: 5d616af4369e04aa - zh: 9bfa38d61ee3e150 + en: 1c378d075d334bb0 + zh: b7bb5b2474df3a89 diff --git a/docs/subsystems/translation.md b/docs/subsystems/translation.md index ae367c1e4e..3e4f98018b 100644 --- a/docs/subsystems/translation.md +++ b/docs/subsystems/translation.md @@ -12,9 +12,9 @@ The service exposes its configured `maxTextChars`, measured in UTF-16 code units ## Failures and data handling -`TranslationError` carries a `TranslationErrorCode` distinguishing text limits, HTTP failures, invalid responses, response limits, and request failures. Provider diagnostics omit submitted text and response bodies. Failed calls do not select another provider. Responses are validated as external JSON and redirects are rejected. +`TranslationError` carries a `TranslationErrorCode` distinguishing text limits, HTTP failures, invalid responses, response limits, owned deadlines, and request failures. Provider diagnostics omit submitted text and response bodies. Failed calls do not select another provider. Responses are validated as external JSON and redirects are rejected. -Google receives source text in the query URL; Bing receives it in a JSON request body. Both are remote services. Their anonymous endpoints provide no supported developer availability or quota guarantee. Regional connectivity must be verified on the user's network. +Google receives source text in a form POST body; Bing receives it in a JSON POST body. Both are remote services. Their anonymous endpoints provide no supported developer availability or quota guarantee. Regional connectivity must be verified on the user's network. @@ -44,7 +44,7 @@ resolve(request: TranslationRequest): TranslationSpec * @param signal - optional caller cancellation, combined with service disposal. * @returns translated plain text; rejects provider/limit failures and preserves cancellation reasons. */ -translate(spec: TranslationSpec, signal?: AbortSignal): Promise +async translate(spec: TranslationSpec, signal?: AbortSignal): Promise ``` Source: [`packages/experimental/translator/src/index.ts`](../../packages/experimental/translator/src/index.ts) diff --git a/docs/subsystems/translation.zh.md b/docs/subsystems/translation.zh.md index 55ff57d77d..02a3b39334 100644 --- a/docs/subsystems/translation.zh.md +++ b/docs/subsystems/translation.zh.md @@ -12,9 +12,9 @@ ## 失败与数据处理 -`TranslationError` 的 `TranslationErrorCode` 区分文本超限、HTTP 失败、无效响应、响应超限与请求失败。Provider 错误信息省略提交的文本与响应体。失败时不选择其他 Provider。响应作为外部 JSON 校验,重定向被拒绝。 +`TranslationError` 的 `TranslationErrorCode` 区分文本超限、HTTP 失败、无效响应、响应超限、服务自身超时与请求失败。Provider 错误信息省略提交的文本与响应体。失败时不选择其他 Provider。响应作为外部 JSON 校验,重定向被拒绝。 -Google 在查询 URL 中接收原文,Bing 在 JSON 请求体中接收原文。两者都是远端服务。免登录端点不保证开发者 API 的可用性或免费额度。区域连通性须在用户网络上验证。 +Google 在 form POST 请求体中接收原文,Bing 在 JSON POST 请求体中接收原文。两者都是远端服务。免登录端点不保证开发者 API 的可用性或免费额度。区域连通性须在用户网络上验证。 @@ -44,7 +44,7 @@ resolve(request: TranslationRequest): TranslationSpec * @param signal - optional caller cancellation, combined with service disposal. * @returns translated plain text; rejects provider/limit failures and preserves cancellation reasons. */ -translate(spec: TranslationSpec, signal?: AbortSignal): Promise +async translate(spec: TranslationSpec, signal?: AbortSignal): Promise ``` Source: [`packages/experimental/translator/src/index.ts`](../../packages/experimental/translator/src/index.ts) diff --git a/packages/experimental/translator/README.i18n.yaml b/packages/experimental/translator/README.i18n.yaml index cc38c94291..a8c53cd5a6 100644 --- a/packages/experimental/translator/README.i18n.yaml +++ b/packages/experimental/translator/README.i18n.yaml @@ -18,8 +18,8 @@ en: d84fddbca98871cf zh: cfac679a3848762a /deepseek-ai-dsh-experimental-translator/understand-the-implementation: - en: 716573bfdb1b2e0d - zh: 59ac75672cb77e98 + en: 7658bdb9787f638e + zh: b0deca12bf2e03d5 /deepseek-ai-dsh-experimental-translator/further-exploration: en: 17792a9fe5429633 zh: b51e5e3c30b0e14d @@ -33,5 +33,5 @@ en: f84dfde870cb510e zh: 695c14ff852b39a8 /deepseek-ai-dsh-experimental-translator/known-limitations-and-deferred-work/dev-note: - en: 0225ba087c5266f3 - zh: 0400e21a2683a23c + en: 64fc90c1cc6f561e + zh: 07d80380df24550a diff --git a/packages/experimental/translator/README.md b/packages/experimental/translator/README.md index 0115ae278c..a803f2afbe 100644 --- a/packages/experimental/translator/README.md +++ b/packages/experimental/translator/README.md @@ -48,9 +48,9 @@ The service maps `zh`, `zh-CN`, `zh-SG` and `zh-Hans` to Simplified Chinese, and
Maintainer details — click to expand -Google receives a GET request using the `gtx` client; Bing receives a JSON text array through Microsoft's Edge endpoint. The selected service receives the submitted text. Cookies, HTTP redirects and automatic provider fallback are disabled. Text and translations remain transient; this service changes neither Session events nor model requests. +Google receives a form POST using the `gtx` client; Bing receives a JSON text array through Microsoft's Edge endpoint. Both receive submitted text in the request body. Cookies, HTTP redirects and automatic provider fallback are disabled. Text and translations remain transient; this service changes neither Session events nor model requests. -`TranslationError.code` distinguishes input limits, HTTP failures, response limits, invalid provider responses and transport failures. Diagnostics include no submitted text or provider error body. Caller cancellation and timeouts retain their original abort reasons. Unloading aborts accepted requests and waits for settlement. Input limits apply in both `resolve()` and `translate()`. +`TranslationError.code` distinguishes input limits, HTTP failures, response limits, invalid provider responses, transport failures and the service's `TRANSLATION_TIMEOUT` deadline. Diagnostics include no submitted text or provider error body. `translate()` always returns a Promise and rejects admission or provider failures. Caller cancellation and service disposal preserve their original abort reasons, including caller-owned timeout reasons. Unloading aborts accepted requests and waits for settlement. Input limits apply in both `resolve()` and `translate()`.
@@ -87,6 +87,6 @@ No direct effect; translation does not change model history.
Maintainer details — click to expand -None. +Live provider tests use the actual default endpoints without credentials. They are skipped unless explicitly enabled with `DSH_TRANSLATION_LIVE=1 pnpm run test:e2e packages/experimental/translator/tests/upstream.e2e.ts`. They cover Bing automatic and explicit source selection, both providers' Chinese locale tags, and a Google request at the default 4000-character limit.
diff --git a/packages/experimental/translator/README.zh.md b/packages/experimental/translator/README.zh.md index 00d643e9b2..4b51d83e00 100644 --- a/packages/experimental/translator/README.zh.md +++ b/packages/experimental/translator/README.zh.md @@ -48,9 +48,9 @@ kind: "package-reference"
维护者细节 — 点击展开 -Google 接收使用 `gtx` 客户端的 GET 请求;Bing 通过 Microsoft Edge 端点接收 JSON 文本数组。所选服务会收到提交的文本。Cookie、HTTP 重定向和自动切换提供者均已禁用。文本和译文保持临时状态;本服务不会修改 Session 事件或模型请求。 +Google 接收使用 `gtx` 客户端的表单 POST 请求;Bing 通过 Microsoft Edge 端点接收 JSON 文本数组。两者都通过请求正文接收提交的文本。Cookie、HTTP 重定向和自动切换提供者均已禁用。文本和译文保持临时状态;本服务不会修改 Session 事件或模型请求。 -`TranslationError.code` 区分输入限制、HTTP 失败、响应限制、无效的提供者响应及传输失败。诊断不包含提交的文本或提供者错误正文。调用者取消和超时保留原始中止原因。卸载时中止已接受的请求并等待它们结束。`resolve()` 和 `translate()` 都执行输入限制。 +`TranslationError.code` 区分输入限制、HTTP 失败、响应限制、无效的提供者响应、传输失败及服务自身的 `TRANSLATION_TIMEOUT` 截止时间。诊断不包含提交的文本或提供者错误正文。`translate()` 始终返回 Promise,准入或提供者失败都会使其拒绝。调用者取消和服务卸载保留原始中止原因,包括调用者自身的超时原因。卸载时中止已接受的请求并等待它们结束。`resolve()` 和 `translate()` 都执行输入限制。
@@ -87,6 +87,6 @@ Google 接收使用 `gtx` 客户端的 GET 请求;Bing 通过 Microsoft Edge
维护者细节 — 点击展开 -无。 +真实提供者测试无需凭据,使用实际默认端点。测试默认跳过,需显式运行 `DSH_TRANSLATION_LIVE=1 pnpm run test:e2e packages/experimental/translator/tests/upstream.e2e.ts` 才启用。它们覆盖 Bing 自动及显式源语言选择、两种提供者的中文区域标签,以及达到默认 4000 字符上限的 Google 请求。
diff --git a/packages/experimental/translator/src/index.ts b/packages/experimental/translator/src/index.ts index f26777fa40..a2598efbdb 100644 --- a/packages/experimental/translator/src/index.ts +++ b/packages/experimental/translator/src/index.ts @@ -25,7 +25,7 @@ export interface Config { bingEndpoint: string /** Deadline covering the request and complete response body, in milliseconds. */ timeoutMs: number - /** Maximum UTF-16 code units of submitted text per request. */ + /** Maximum UTF-16 code units per request; at least two to admit one supplementary-plane character. */ maxTextChars: number /** Maximum response body bytes before JSON parsing. */ maxResponseBytes: number @@ -43,8 +43,8 @@ function endpoint(value: string): string { export default class Translator extends Service { static Config = z.object({ provider: z.union(['google', 'bing'] as const).default('bing'), - googleEndpoint: z.transform(z.string().default('https://translate.googleapis.com/translate_a/single'), endpoint), - bingEndpoint: z.transform(z.string().default('https://edge.microsoft.com/translate/translatetext'), endpoint), + googleEndpoint: z.transform(z.string(), endpoint).default('https://translate.googleapis.com/translate_a/single'), + bingEndpoint: z.transform(z.string(), endpoint).default('https://edge.microsoft.com/translate/translatetext'), timeoutMs: z.natural().min(1).max(2_147_483_647).default(10_000), maxTextChars: z.natural().min(2).default(4_000), maxResponseBytes: z.natural().min(1).default(1024 * 1024), @@ -82,7 +82,7 @@ export default class Translator extends Service { * @param signal - optional caller cancellation, combined with service disposal. * @returns translated plain text; rejects provider/limit failures and preserves cancellation reasons. */ - translate(spec: TranslationSpec, signal?: AbortSignal): Promise { + async translate(spec: TranslationSpec, signal?: AbortSignal): Promise { this.lifetime.signal.throwIfAborted() this.assertTextLimit(spec.text) const combined = signal === undefined ? this.lifetime.signal : AbortSignal.any([signal, this.lifetime.signal]) diff --git a/packages/experimental/translator/src/provider.ts b/packages/experimental/translator/src/provider.ts index c4cf40ce2e..a4a4a82271 100644 --- a/packages/experimental/translator/src/provider.ts +++ b/packages/experimental/translator/src/provider.ts @@ -1,5 +1,5 @@ /** Anonymous provider protocols, bounded JSON intake and normalized Chinese language tags. */ -import { deadline } from '@deepseek-ai/dsh-timeout' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { assertNever } from '@deepseek-ai/dsh-util-values' import type { Config } from './index.ts' import type { TranslationProvider, TranslationSpec } from './types.ts' @@ -18,10 +18,10 @@ function requestFor(spec: TranslationSpec, config: Config): { url: URL; init: Re switch (spec.provider) { case 'google': { const url = new URL(config.googleEndpoint) - for (const [key, value] of Object.entries({ client: 'gtx', sl: source, tl: target, dt: 't', q: spec.text })) { - url.searchParams.set(key, value) - } - return { url, init: { method: 'GET' } } + const form = new URLSearchParams({ client: 'gtx', sl: source, tl: target, dt: 't', q: spec.text }) + for (const key of form.keys()) url.searchParams.delete(key) + return { url, init: { method: 'POST', body: form.toString(), + headers: { 'content-type': 'application/x-www-form-urlencoded' } } } } case 'bing': { const url = new URL(config.bingEndpoint) @@ -30,7 +30,7 @@ function requestFor(spec: TranslationSpec, config: Config): { url: URL; init: Re url.searchParams.set('to', target) url.searchParams.set('isEnterpriseClient', 'false') return { url, init: { method: 'POST', body: JSON.stringify([spec.text]), - headers: { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (compatible; DeepSeekHarnessTranslator)' } } } + headers: { 'content-type': 'application/json' } } } } /* v8 ignore next -- TranslationProvider is a closed typed union; JSON/config validation owns admission. */ default: return assertNever(spec.provider) @@ -119,6 +119,10 @@ export async function translateText(spec: TranslationSpec, config: Config, signa timeout.signal.throwIfAborted() return translationOf(payload, spec.provider) } catch (error) { + const expired = timeoutOf(timeout.signal, 'TRANSLATION_TIMEOUT') + if (expired !== undefined && expired !== signal.reason) { + throw new TranslationError('TRANSLATION_TIMEOUT', `${spec.provider} translation timed out after ${expired.timeoutMs}ms`) + } timeout.signal.throwIfAborted() if (error instanceof TranslationError) throw error throw new TranslationError('TRANSLATION_REQUEST_FAILED', `${spec.provider} translation request failed`) diff --git a/packages/experimental/translator/src/types.ts b/packages/experimental/translator/src/types.ts index ae57724434..fda0a9505e 100644 --- a/packages/experimental/translator/src/types.ts +++ b/packages/experimental/translator/src/types.ts @@ -29,4 +29,4 @@ export interface TranslationSpec { /** Failures distinct from caller cancellation and service disposal. */ export type TranslationErrorCode = 'TRANSLATION_TEXT_LIMIT' | 'TRANSLATION_HTTP_ERROR' - | 'TRANSLATION_INVALID_RESPONSE' | 'TRANSLATION_RESPONSE_LIMIT' | 'TRANSLATION_REQUEST_FAILED' + | 'TRANSLATION_INVALID_RESPONSE' | 'TRANSLATION_RESPONSE_LIMIT' | 'TRANSLATION_REQUEST_FAILED' | 'TRANSLATION_TIMEOUT' diff --git a/packages/experimental/translator/tests/translator.spec.ts b/packages/experimental/translator/tests/translator.spec.ts index c725967994..1da2f7e7ee 100644 --- a/packages/experimental/translator/tests/translator.spec.ts +++ b/packages/experimental/translator/tests/translator.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { TimeoutReason } from '@deepseek-ai/dsh-timeout' import { boot } from '@deepseek-ai/dsh-app-boot' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { once } from 'node:events' @@ -41,7 +42,8 @@ async function fixture(reply: Reply, overrides: Partial = {}) { const base = `http://127.0.0.1:${address.port}` const ctx = new Context() cleanups.push(async () => { await ctx.fiber.dispose() }) - const config = Translator.Config({ googleEndpoint: `${base}/google`, bingEndpoint: `${base}/bing?from=fr`, ...overrides }) + const config = Translator.Config({ googleEndpoint: `${base}/google?client=legacy&q=stale&sl=fr&tl=ja&dt=x&fixture=retained`, + bingEndpoint: `${base}/bing?from=fr`, ...overrides }) const fiber = ctx.plugin(Translator, config) await fiber return { ctx, fiber, config, requests, translator: ctx.translator } @@ -63,8 +65,10 @@ describe('anonymous translator', () => { expect(translator.maxTextChars).toBe(4000) expect(await translator.translate(spec)).toBe('你好,世界。') const request = requests[0]! - expect(request.method).toBe('GET') - expect(Object.fromEntries(request.url.searchParams)).toEqual({ client: 'gtx', sl: 'auto', tl: 'zh-CN', dt: 't', q: 'Hello world.' }) + expect(request.method).toBe('POST') + expect(request.headers['content-type']).toBe('application/x-www-form-urlencoded') + expect(Object.fromEntries(request.url.searchParams)).toEqual({ fixture: 'retained' }) + expect(Object.fromEntries(new URLSearchParams(request.body))).toEqual({ client: 'gtx', sl: 'auto', tl: 'zh-CN', dt: 't', q: 'Hello world.' }) expect(request.headers.cookie).toBeUndefined() expect(request.headers.authorization).toBeUndefined() }) @@ -83,8 +87,8 @@ describe('anonymous translator', () => { expect(JSON.parse(explicit.body)).toEqual(['hello']) expect(Object.fromEntries(explicit.url.searchParams)).toEqual({ from: 'zh-Hant', to: 'en', isEnterpriseClient: 'false' }) expect(await translator.translate(translator.resolve({ text: 'hello', targetLanguage: 'zh-TW', sourceLanguage: 'en', provider: 'google' }))).toBe('你好,世界。') - expect(requests[2]!.url.searchParams.get('tl')).toBe('zh-TW') - expect(requests[2]!.url.searchParams.get('sl')).toBe('en') + expect(new URLSearchParams(requests[2]!.body).get('tl')).toBe('zh-TW') + expect(new URLSearchParams(requests[2]!.body).get('sl')).toBe('en') }) it('maps Simplified and Traditional tags to Bing language codes', async () => { @@ -101,7 +105,7 @@ describe('anonymous translator', () => { expect(requests).toHaveLength(1) expect(() => translator.resolve({ text: '😀a', targetLanguage: 'zh' })).toThrow(TranslationError) const oversized: TranslationSpec = { text: '😀a', targetLanguage: 'zh', sourceLanguage: 'auto', provider: 'google' } - expect(() => translator.translate(oversized)).toThrow(expect.objectContaining({ code: 'TRANSLATION_TEXT_LIMIT' })) + await expect(translator.translate(oversized)).rejects.toThrow(expect.objectContaining({ code: 'TRANSLATION_TEXT_LIMIT' })) expect(requests).toHaveLength(1) }) @@ -159,27 +163,28 @@ describe('anonymous translator', () => { .rejects.toThrow(expect.objectContaining({ code: 'TRANSLATION_RESPONSE_LIMIT' })) }) - it('honors caller cancellation before admission and during response intake', async () => { - const entered = Promise.withResolvers() - const { translator, requests } = await fixture((_req, res) => { res.writeHead(200); res.write('['); entered.resolve(undefined) }) - const spec = translator.resolve({ text: 'hello', targetLanguage: 'zh' }) - const already = new AbortController() - const reason = new Error('consumer stopped translation') - already.abort(reason) - expect(() => translator.translate(spec, already.signal)).toThrow(reason) - expect(requests).toHaveLength(0) - const controller = new AbortController() - const pending = translator.translate(spec, controller.signal) - const rejection = expect(pending).rejects.toBe(reason) - await entered.promise - controller.abort(reason) - await rejection - }) + it.each([new Error('consumer stopped translation'), new TimeoutReason('TRANSLATION_TIMEOUT', 1)])( + 'preserves caller cancellation before admission and during response intake: %s', async (reason) => { + const entered = Promise.withResolvers() + const { translator, requests } = await fixture((_req, res) => { res.writeHead(200); res.write('['); entered.resolve(undefined) }) + const spec = translator.resolve({ text: 'hello', targetLanguage: 'zh' }) + const already = new AbortController() + already.abort(reason) + await expect(translator.translate(spec, already.signal)).rejects.toBe(reason) + expect(requests).toHaveLength(0) + const controller = new AbortController() + const pending = translator.translate(spec, controller.signal) + const rejection = expect(pending).rejects.toBe(reason) + await entered.promise + controller.abort(reason) + await rejection + }) it('applies its deadline while a provider leaves the response unfinished', async () => { const { translator } = await fixture((_req, res) => { res.writeHead(200); res.write('[') }, { timeoutMs: 40 }) await expect(translator.translate(translator.resolve({ text: 'hello', targetLanguage: 'zh' }))) - .rejects.toThrow(expect.objectContaining({ code: 'TRANSLATION_TIMEOUT', timeoutMs: 40 })) + .rejects.toThrow(expect.objectContaining({ name: 'TranslationError', code: 'TRANSLATION_TIMEOUT', + message: 'bing translation timed out after 40ms' })) }) it('unloads the service after aborting and joining accepted translations', async () => { @@ -193,11 +198,13 @@ describe('anonymous translator', () => { await rejection expect(ctx.get('translator')).toBeUndefined() expect(() => translator.resolve({ text: 'hello', targetLanguage: 'zh' })).toThrow('Translator service disposed') - expect(() => translator.translate(spec)).toThrow('Translator service disposed') + await expect(translator.translate(spec)).rejects.toThrow('Translator service disposed') }) it('validates endpoint URLs and numerical limits before activation', () => { - expect(Translator.Config({})).toMatchObject({ provider: 'bing', maxTextChars: 4000, timeoutMs: 10000 }) + expect(Translator.Config({})).toMatchObject({ provider: 'bing', maxTextChars: 4000, timeoutMs: 10000, + googleEndpoint: 'https://translate.googleapis.com/translate_a/single', + bingEndpoint: 'https://edge.microsoft.com/translate/translatetext' }) for (const googleEndpoint of ['not a URL', 'ftp://provider.invalid/', 'https://user:password@provider.invalid/', 'https://provider.invalid/#fragment']) { expect(() => Translator.Config({ googleEndpoint })).toThrow() } diff --git a/packages/experimental/translator/tests/upstream.e2e.ts b/packages/experimental/translator/tests/upstream.e2e.ts new file mode 100644 index 0000000000..5c87dda8e1 --- /dev/null +++ b/packages/experimental/translator/tests/upstream.e2e.ts @@ -0,0 +1,46 @@ +/** Opt-in native-fetch probes of the actual anonymous browser endpoints. */ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it } from 'vitest' +import Translator from '../src/index.ts' + +const contexts: Context[] = [] +afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) }) + +async function translator() { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Translator, Translator.Config({ timeoutMs: 30_000 })) + return ctx.translator +} + +describe.skipIf(process.env.DSH_TRANSLATION_LIVE !== '1')('anonymous translation real endpoints', () => { + it('uses default Bing with automatic source detection and a Simplified Chinese locale', async () => { + const service = await translator() + const spec = service.resolve({ text: 'Hello world.', targetLanguage: 'zh-CN' }) + expect(spec.provider).toBe('bing') + expect(spec.sourceLanguage).toBe('auto') + expect(await service.translate(spec)).toContain('世界') + }) + + it('uses default Bing with an explicit English source and a Traditional Chinese locale', async () => { + const service = await translator() + const spec = service.resolve({ text: 'The computer processes data.', targetLanguage: 'zh-TW', sourceLanguage: 'en' }) + expect(spec.provider).toBe('bing') + expect(await service.translate(spec)).toMatch(/電腦|計算機/u) + }) + + it.each(['zh-Hans', 'zh-Hant'])('uses explicit Google with Chinese tag %s', async (targetLanguage) => { + const service = await translator() + const spec = service.resolve({ text: 'The computer processes data.', targetLanguage, sourceLanguage: 'en', provider: 'google' }) + const result = await service.translate(spec) + expect(result).toMatch(targetLanguage === 'zh-Hans' ? /电脑|计算机/u : /電腦|計算機/u) + }) + + it('translates 4000 CJK characters through the Google form endpoint', async () => { + const service = await translator() + const text = '你好世界。'.repeat(800) + expect(text.length).toBe(service.maxTextChars) + const result = await service.translate(service.resolve({ text, targetLanguage: 'en', sourceLanguage: 'zh-CN', provider: 'google' })) + expect(result).toMatch(/world/i) + }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 81777a4db6..aa9e7f9b88 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3347,7 +3347,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a complete specification; exceeding `maxTextChars` throws `TRANSLATION_TEXT_LIMIT`.', }, { - signature: 'translate(spec: TranslationSpec, signal?: AbortSignal): Promise', + signature: 'async translate(spec: TranslationSpec, signal?: AbortSignal): Promise', description: 'Translate one resolved specification; the selected provider receives its text.', parameters: [{ name: 'spec', description: 'complete routing and language choices from `resolve()`.' }, { name: 'signal', description: 'optional caller cancellation, combined with service disposal.' }], returns: 'translated plain text; rejects provider/limit failures and preserves cancellation reasons.',