diff --git a/docs/testing/unit/engines/byok-skeleton.test.ts b/docs/testing/unit/engines/byok-skeleton.test.ts new file mode 100644 index 0000000..f490fd8 --- /dev/null +++ b/docs/testing/unit/engines/byok-skeleton.test.ts @@ -0,0 +1,225 @@ +/** + * engines/byok.ts — 自带 key 引擎公共构造骨架测试(#333) + * + * 验证骨架顺序与契约: + * - 闸门包裹整个请求体(含取 key):并发超限时第二个请求等待, + * 取 key 也发生在闸门内 + * - 缺 key 时不发请求,抛不可重试的 key 无效类错误 + * - 成功:请求构造 → 公共分类 → 响应解析(适配器只留端点/头/体/解析) + * - 失败分类走公共判定:401/403 → key 无效、429 → 配额、5xx → 瞬时 + * - 适配器 classifyError 特例优先于公共分类 + */ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createByokEngine } from '~/src/engines/byok'; +import type { ByokEngineSpec } from '~/src/engines/byok'; +import { EngineError } from '~/src/engines/types'; + +vi.mock('~/src/storage/keys', () => ({ + getKey: vi.fn(), +})); + +vi.mock('~/src/storage/settings', () => ({ + getSettings: vi.fn(() => ({ maxConcurrency: 1 })), + onSettingsChanged: vi.fn(() => () => {}), +})); + +/** 最小适配器:原样回显第 0 条文本。 */ +function makeSpec(overrides: Partial = {}): ByokEngineSpec { + return { + id: 'openai', + displayName: 'Test', + supportedLangs: 'all', + buildRequest: ({ texts }, key) => ({ + url: 'https://example.com/translate', + headers: { Authorization: `Bearer ${key}` }, + body: JSON.stringify({ text: texts[0] }), + }), + parseResponse: (data) => ({ + translations: [(data as { text: string }).text], + }), + ...overrides, + }; +} + +let fetchMock: ReturnType; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + fetchMock = vi.fn(async () => + new Response(JSON.stringify({ text: '你好' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function withKey(key: string | undefined): Promise { + const { getKey } = await import('~/src/storage/keys'); + vi.mocked(getKey).mockResolvedValue(key as never); +} + +describe('createByokEngine 骨架(#333)', () => { + test('成功:请求构造(端点/头/体)与响应解析按适配器执行', async () => { + await withKey('sk-test'); + const engine = createByokEngine(makeSpec()); + const result = await engine.translate({ + texts: ['Hello'], + from: 'auto', + to: 'zh', + }); + + expect(result.translations).toEqual(['你好']); + const [url, init] = fetchMock.mock.calls[0]! as [string, RequestInit]; + expect(url).toBe('https://example.com/translate'); + expect((init.headers as Record).Authorization).toBe( + 'Bearer sk-test', + ); + expect(JSON.parse(String(init.body))).toEqual({ text: 'Hello' }); + // 路由面:引擎对象满足 TranslateEngine interface(requiresKey 等) + expect(engine).toMatchObject({ + id: 'openai', + requiresKey: true, + supportedLangs: 'all', + }); + }); + + test('缺 key:不发请求,抛不可重试的 key 无效类错误', async () => { + await withKey(undefined); + const engine = createByokEngine(makeSpec()); + + await expect( + engine.translate({ texts: ['Hello'], from: 'auto', to: 'zh' }), + ).rejects.toMatchObject({ + engineId: 'openai', + retryable: false, + category: 'invalid-key', + message: '未配置 API key', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('闸门包裹整个请求体(含取 key):并发超限时第二个请求等待', async () => { + const { getKey } = await import('~/src/storage/keys'); + vi.mocked(getKey).mockResolvedValue('k' as never); + let inFlight = 0; + let maxInFlight = 0; + const resolvers: Array<() => void> = []; + fetchMock.mockImplementation( + () => + new Promise((r) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + resolvers.push(() => { + inFlight--; + r( + new Response(JSON.stringify({ text: '你好' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + }), + ); + + const engine = createByokEngine(makeSpec()); + const p1 = engine.translate({ texts: ['a'], from: 'auto', to: 'zh' }); + const p2 = engine.translate({ texts: ['b'], from: 'auto', to: 'zh' }); + + // 等待第一个请求进入在飞状态(轮询,避免 waitFor 与闸门时序耦合) + for (let i = 0; i < 100 && inFlight < 1; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(inFlight).toBe(1); + // maxConcurrency=1:第二个请求被闸门挡住,未触达 fetch + await new Promise((r) => setTimeout(r, 20)); + expect(inFlight).toBe(1); + expect(maxInFlight).toBe(1); + + // 释放第一个在飞请求 —— 闸门放行第二个后才发出其 fetch + for (const release of resolvers) release(); + for (let i = 0; i < 100 && resolvers.length < 2; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + for (const release of resolvers) release(); + await Promise.all([p1, p2]); + expect(maxInFlight).toBe(1); + }); + + test('失败分类走公共判定:401/403 → key 无效、429 → 配额、5xx → 瞬时', async () => { + await withKey('k'); + const engine = createByokEngine(makeSpec()); + const cases: Array<[number, string]> = [ + [401, 'invalid-key'], + [403, 'invalid-key'], + [429, 'quota'], + [500, 'transient'], + ]; + for (const [status, category] of cases) { + fetchMock.mockResolvedValue(new Response('err', { status })); + await expect( + engine.translate({ texts: ['a'], from: 'auto', to: 'zh' }), + ).rejects.toMatchObject({ + engineId: 'openai', + category, + retryable: category === 'transient', + }); + } + }); + + test('适配器 classifyError 特例优先于公共分类', async () => { + await withKey('k'); + // 400 + 错误体明示认证失败 → 适配器特例归为 key 无效 + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + error: { status: 'UNAUTHENTICATED', message: 'API key not valid' }, + }), + { status: 400 }, + ), + ); + const engine = createByokEngine( + makeSpec({ + classifyError: async (resp) => { + const body = (await resp.json()) as { error?: { status?: string } }; + if (body.error?.status === 'UNAUTHENTICATED') { + return new EngineError('openai', false, 'API key 无效', 'invalid-key'); + } + return null; + }, + }), + ); + + await expect( + engine.translate({ texts: ['a'], from: 'auto', to: 'zh' }), + ).rejects.toMatchObject({ + category: 'invalid-key', + message: 'API key 无效', + }); + }); + + test('模型名经 spec.model 提供并传入 buildRequest', async () => { + await withKey('k'); + const model = vi.fn(() => 'gpt-4o'); + const buildRequest = vi.fn((req: unknown, key: string, m?: string) => ({ + url: 'https://example.com', + headers: {}, + body: JSON.stringify({ key, model: m }), + })); + const engine = createByokEngine(makeSpec({ model, buildRequest })); + + await engine.translate({ texts: ['a'], from: 'auto', to: 'zh' }); + + expect(model).toHaveBeenCalled(); + expect(buildRequest).toHaveBeenCalledWith( + expect.anything(), + 'k', + 'gpt-4o', + ); + }); +}); diff --git a/package.json b/package.json index 4279bc5..95cc91d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallel-translation", - "version": "2.0.62", + "version": "2.0.63", "description": "对照式网页翻译浏览器扩展", "private": true, "type": "module", diff --git a/src/engines/byok.ts b/src/engines/byok.ts new file mode 100644 index 0000000..11529c9 --- /dev/null +++ b/src/engines/byok.ts @@ -0,0 +1,105 @@ +// 自带 key 引擎公共构造 —— #309 架构评审候选 6(#333)。 +// +// openai / deepl / gemini 三家自带 key 引擎此前各自重复同一段骨架: +// 过并发闸门 → 取 key → 缺 key 抛不可重试错误 → 发请求 → 按公共判定 +// 分类 → 按类别抛错。本模块把这段骨架收敛为 createByokEngine: +// - 闸门保持在最外层:整个请求体(含取 key)都在闸门内 +// - 每家引擎仍持有各自的模块级惰性单例闸门(engineGate 实例) +// - 缺 key 时不发请求,抛不可重试的 key 无效类错误 +// - 失败分类走公共判定 classifyStatus;需要读错误响应体才能定类别 +// 的引擎(Gemini)由适配器经 classifyError 提供特例 +// 适配器只保留:端点、请求头、请求体构造、响应解析。 + +import { getKey } from '~/src/storage/keys'; +import type { EngineId } from '~/src/storage/schema'; +import { fetchWithTimeout } from './fetch-timeout'; +import { engineGate } from './engine-gate'; +import { EngineError } from './types'; +import { classifyStatus } from './shared'; +import type { TranslateEngine, TranslateRequest, TranslateResponse } from './types'; + +/** 适配器提供的引擎特例(端点 / 请求构造 / 响应解析 / 错误体特例)。 */ +export interface ByokEngineSpec { + id: EngineId; + displayName: string; + supportedLangs: string[] | 'all'; + /** 模型名(有模型概念的引擎);无则省略。 */ + model?: () => string; + /** + * 构造请求(key 已取到,model 已计算)。凭据一律走请求头, + * 不进查询串。 + */ + buildRequest( + req: TranslateRequest, + key: string, + model?: string, + ): { url: string; headers: Record; body?: string }; + /** 解析成功响应为译文(长度必须与请求文本数一致)。 */ + parseResponse(data: unknown, expected: number): TranslateResponse; + /** + * 错误分类特例(读错误响应体才能定类别的引擎)—— 返回非 null 即 + * 采用该错误;返回 null 走公共状态码分类(#239)。 + */ + classifyError?(resp: Response): Promise; +} + +/** + * 自带 key 引擎公共构造(#333)。 + * 骨架顺序:闸门包裹整个请求体(含取 key)→ 缺 key 抛不可重试的 + * key 无效类错误 → 构造请求 → 发送 → 公共分类 / 适配器特例 → 按类别 + * 抛错 → 成功解析。 + */ +export function createByokEngine(spec: ByokEngineSpec): TranslateEngine { + // #159: 引擎级并发闸门 —— 每家引擎持有各自的模块级惰性单例 + const getGate = engineGate(); + + return { + id: spec.id, + displayName: spec.displayName, + requiresKey: true, + supportedLangs: spec.supportedLangs, + + async translate(req) { + // #333: 闸门保持在最外层 —— 整个请求体(含取 key)都在闸门内 + return getGate()(async () => { + const key = await getKey(spec.id); + if (!key) + throw new EngineError( + spec.id, + false, + '未配置 API key', + 'invalid-key', + ); + + const model = spec.model?.(); + const { url, headers, body } = spec.buildRequest(req, key, model); + + const resp = await fetchWithTimeout(spec.id, url, { + method: 'POST', + headers, + ...(body ? { body } : {}), + }); + + if (!resp.ok) { + // 适配器特例优先(读错误体定类别) + if (spec.classifyError) { + const special = await spec.classifyError(resp); + if (special) throw special; + } + // 公共状态分类(#239):401/403 → key 无效、429 → 配额、其余 → 瞬时 + const category = classifyStatus(spec.id, resp, true); + if (category === 'invalid-key') { + throw new EngineError(spec.id, false, 'API key 无效', 'invalid-key'); + } + if (category === 'quota') { + throw new EngineError(spec.id, false, '配额已用尽', 'quota', true); + } + throw new EngineError(spec.id, true, `HTTP ${resp.status}`, 'transient'); + } + + const data = await resp.json(); + return spec.parseResponse(data, req.texts.length); + }); + }, + }; +} diff --git a/src/engines/openai.ts b/src/engines/openai.ts index 7e99a42..4de7b84 100644 --- a/src/engines/openai.ts +++ b/src/engines/openai.ts @@ -1,22 +1,19 @@ // Phase 7 — OpenAI 兼容翻译引擎(BYOK)。 // 端点默认 api.openai.com/v1,也可用于任何兼容的 OpenAI API 代理。 // 批量策略:编号后整批送,一次往返拿回全部译文,避免逐段请求的高延迟和高费用。 +// +// #333: 骨架(闸门 / 取 key / 分类抛错)收敛到公共构造 createByokEngine, +// 本文件只保留端点、请求头、请求体构造、响应解析。 -import { getKey } from '~/src/storage/keys'; import { getSettings } from '~/src/storage/settings'; import { DEFAULT_MODELS } from '~/src/storage/schema'; -import { fetchWithTimeout } from './fetch-timeout'; -import { engineGate } from './engine-gate'; -import { EngineError } from './types'; -import { classifyStatus, buildNumberedPrompt } from './shared'; +import { buildNumberedPrompt } from './shared'; +import { createByokEngine } from './byok'; import type { ProbeSpec } from './shared'; import type { TranslateEngine } from './types'; const DEFAULT_ENDPOINT = 'https://api.openai.com/v1/chat/completions'; -// #159: 引擎级并发闸门 —— 整页翻译批次并发 → translate() 并发调用 -const getGate = engineGate(); - /** 连通性探测规格(#321):GET /v1/models,凭据走请求头。 */ export const openaiProbe: ProbeSpec = { engineId: 'openai', @@ -40,54 +37,39 @@ export function parseNumbered(raw: string, expected: number): string[] { return Array.from({ length: expected }, (_, i) => map.get(i + 1) ?? ''); } -export const openai: TranslateEngine = { +/** 当前生效的模型名(设置优先,缺省回落到 schema 默认)。 */ +function currentModel(): string { + return getSettings().models?.openai ?? DEFAULT_MODELS.openai!; +} + +export const openai: TranslateEngine = createByokEngine({ id: 'openai', displayName: 'OpenAI', - requiresKey: true, supportedLangs: 'all', + model: currentModel, - async translate({ texts, from, to }) { - // #159: 整个请求体过闸门,限制并发在飞请求数 - return getGate()(async () => { - const key = await getKey('openai'); - if (!key) - throw new EngineError('openai', false, '未配置 API key', 'invalid-key'); - - const model = getSettings().models?.openai ?? DEFAULT_MODELS.openai!; - + // 请求格式与改造前完全一致(#333) + buildRequest: ({ texts, from, to }, key) => ({ + url: DEFAULT_ENDPOINT, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${key}`, + }, + body: JSON.stringify({ + model: currentModel(), // #258: 编号提示词走公共模板(模板唯一来源)—— 格式与现状逐字一致 - const prompt = buildNumberedPrompt(to, from, texts); - - const resp = await fetchWithTimeout('openai', DEFAULT_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${key}`, - }, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content: prompt }], - temperature: 0, - }), - }); - - // #258: 状态分类走公共判定(口径:#239)—— 401/403 → key 无效、 - // 429 → 配额、其余非 2xx → 瞬时 - const category = classifyStatus('openai', resp, true); - if (category === 'invalid-key') { - throw new EngineError('openai', false, 'API key 无效', 'invalid-key'); - } - if (category === 'quota') { - throw new EngineError('openai', false, '配额已用尽', 'quota', true); - } - if (!resp.ok) { - throw new EngineError('openai', true, `HTTP ${resp.status}`, 'transient'); - } + messages: [{ role: 'user', content: buildNumberedPrompt(to, from, texts) }], + temperature: 0, + }), + }), - const data = await resp.json(); - const raw = data.choices?.[0]?.message?.content ?? ''; - const out = parseNumbered(raw, texts.length); - return { translations: out }; - }); + parseResponse: (data, expected) => { + const raw = + ( + data as { + choices?: Array<{ message?: { content?: string } }>; + } + ).choices?.[0]?.message?.content ?? ''; + return { translations: parseNumbered(raw, expected) }; }, -}; +});