fix(translator): apply endpoint defaults and bound upstream failures

This commit is contained in:
Tianyi Cui committed 2026-10-05 11:09:32 +08:00
1 parent dede7b20e2
commit d1d560ba4b
14 files changed
+114 -57

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -9,5 +9,5 @@
en: 9d963e74f160b5ef
zh: 99f5a7c187148743
/text-translation/failures-and-data-handling:
en: 5d616af4369e04aa
zh: 9bfa38d61ee3e150
en: 1c378d075d334bb0
zh: b7bb5b2474df3a89
+3 -3
View File
@@ -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.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -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<string>
async translate(spec: TranslationSpec, signal?: AbortSignal): Promise<string>
```
Source: [`packages/experimental/translator/src/index.ts`](../../packages/experimental/translator/src/index.ts)
+3 -3
View File
@@ -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 的可用性或免费额度。区域连通性须在用户网络上验证。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -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<string>
async translate(spec: TranslationSpec, signal?: AbortSignal): Promise<string>
```
Source: [`packages/experimental/translator/src/index.ts`](../../packages/experimental/translator/src/index.ts)
@@ -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
+3 -3
View File
@@ -48,9 +48,9 @@ The service maps `zh`, `zh-CN`, `zh-SG` and `zh-Hans` to Simplified Chinese, and
<details>
<summary>Maintainer details — click to expand</summary>
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()`.
</details>
@@ -87,6 +87,6 @@ No direct effect; translation does not change model history.
<details>
<summary>Maintainer details — click to expand</summary>
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.
</details>
@@ -48,9 +48,9 @@ kind: "package-reference"
<details>
<summary>维护者细节 — 点击展开</summary>
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()` 都执行输入限制。
</details>
@@ -87,6 +87,6 @@ Google 接收使用 `gtx` 客户端的 GET 请求;Bing 通过 Microsoft Edge
<details>
<summary>维护者细节 — 点击展开</summary>
无。
真实提供者测试无需凭据,使用实际默认端点。测试默认跳过,需显式运行 `DSH_TRANSLATION_LIVE=1 pnpm run test:e2e packages/experimental/translator/tests/upstream.e2e.ts` 才启用。它们覆盖 Bing 自动及显式源语言选择、两种提供者的中文区域标签,以及达到默认 4000 字符上限的 Google 请求。
</details>
@@ -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<string> {
async translate(spec: TranslationSpec, signal?: AbortSignal): Promise<string> {
this.lifetime.signal.throwIfAborted()
this.assertTextLimit(spec.text)
const combined = signal === undefined ? this.lifetime.signal : AbortSignal.any([signal, this.lifetime.signal])
@@ -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`)
@@ -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'
@@ -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<Config> = {}) {
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<undefined>()
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<undefined>()
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:[email protected]/', 'https://provider.invalid/#fragment']) {
expect(() => Translator.Config({ googleEndpoint })).toThrow()
}
@@ -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)
})
})
@@ -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<string>',
signature: 'async translate(spec: TranslationSpec, signal?: AbortSignal): Promise<string>',
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.',