diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts index 3b04ece1e7..572118b7eb 100644 --- a/apps/web/tests/cot-translation-paid.e2e.ts +++ b/apps/web/tests/cot-translation-paid.e2e.ts @@ -58,6 +58,13 @@ async function messageBody(request: IncomingMessage): Promise } +/** Account model listing at the Messages origin; the version name is arbitrary because selection uses the id. */ +function listing(response: ServerResponse): void { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ object: 'list', data: [{ id: 'deepseek-flash', name: 'Native Flash arbitrary future version', + api_capabilities: { anthropic_messages: {} } }] })) +} + /** Complete external native Messages SSE; no Host method or adapter is replaced. */ function reply(response: ServerResponse, text: string): void { response.writeHead(200, { 'content-type': 'text/event-stream' }) @@ -81,6 +88,7 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr let holdNext = false async function respond(request: IncomingMessage, response: ServerResponse): Promise { const path = new URL(request.url ?? '/', 'http://native.test').pathname + if (path === '/models') { listing(response); return } if (path !== '/anthropic/v1/messages') throw new Error(`Unexpected external translation request: ${path}`) const auditSessionId = resources.sessionId if (auditSessionId === undefined || resources.scaffold === undefined) throw new Error('Paid I/O preceded the recorded Session') @@ -123,8 +131,7 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr const origin = `http://127.0.0.1:${address.port}` const overlay = join(root, 'paid-translation.patch.yml') await writeFile(overlay, JSON.stringify([ - { id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic`, reasoningEffort: 'high', - models: [{ id: 'deepseek-flash', name: 'Native Flash arbitrary future version' }] } }, + { id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic`, reasoningEffort: 'high' } }, { id: 'translator', config: { bingEndpoint: `${origin}/unexpected-free`, googleEndpoint: `${origin}/unexpected-free`, deepseekTimeoutMs: 120_000 } }, ])) diff --git a/docs/subsystems/translation.i18n.yaml b/docs/subsystems/translation.i18n.yaml index 51904d7464..58d834341f 100644 --- a/docs/subsystems/translation.i18n.yaml +++ b/docs/subsystems/translation.i18n.yaml @@ -9,8 +9,8 @@ en: 485abf6107c73e68 zh: 78a274d488274035 /text-translation/failures-and-data-handling: - en: cf92a1920650888c - zh: 3e8d3aa244cc8d46 + en: 436e55e924e3ad1e + zh: c8548fda1e86b078 /text-translation/persistent-translations: en: 40d175fbe66fd4cd zh: bff18511a8d386e9 diff --git a/docs/subsystems/translation.md b/docs/subsystems/translation.md index 8cbf6d3ac8..306b5baf84 100644 --- a/docs/subsystems/translation.md +++ b/docs/subsystems/translation.md @@ -14,7 +14,7 @@ The service exposes its configured `maxTextChars`, measured in UTF-16 code units `TranslationError` carries a `TranslationErrorCode` distinguishing text limits, HTTP failures, invalid responses, response limits, owned deadlines, and request failures. `TRANSLATION_SESSION_REQUIRED` rejects a missing persistence backend or Session; `TRANSLATION_SESSION_INACTIVE` rejects an uncached inactive Session before provider dispatch; `TRANSLATION_STORAGE_ERROR` rejects invalid saved records or failed durability. 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. -`TRANSLATION_UNAVAILABLE` rejects a missing active built-in owner or registered route, unconfigured credentials, absent exact `deepseek-flash` catalog membership, an owner/model-list/official credential-reference change before dispatch, provider withdrawal, or a prepared call whose reasoning effort is not `off`. A native `finish` with reason `error` maps to `TRANSLATION_REQUEST_FAILED`; a missing or other non-`stop` finish maps to `TRANSLATION_INVALID_RESPONSE`. +`TRANSLATION_UNAVAILABLE` rejects a missing active built-in owner or registered route, unconfigured credentials, absent exact `deepseek-flash` catalog membership, an owner, model-configuration (Official `models` or Account `modelOverrides`), or Official credential-reference change before dispatch, provider withdrawal, or a prepared call whose reasoning effort is not `off`. A native `finish` with reason `error` maps to `TRANSLATION_REQUEST_FAILED`; a missing or other non-`stop` finish maps to `TRANSLATION_INVALID_RESPONSE`. 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. diff --git a/docs/subsystems/translation.zh.md b/docs/subsystems/translation.zh.md index 4600d369e4..a9ef52df83 100644 --- a/docs/subsystems/translation.zh.md +++ b/docs/subsystems/translation.zh.md @@ -14,7 +14,7 @@ `TranslationError` 的 `TranslationErrorCode` 区分文本超限、HTTP 失败、无效响应、响应超限、服务自身超时与请求失败。`TRANSLATION_SESSION_REQUIRED` 拒绝缺失的持久化后端或 Session,`TRANSLATION_SESSION_INACTIVE` 在调用 Provider 前拒绝未激活且缓存未命中的 Session,`TRANSLATION_STORAGE_ERROR` 拒绝无效保存记录或持久化失败。Provider 错误信息省略提交的文本与响应体。失败时不选择其他 Provider。响应作为外部 JSON 校验,重定向被拒绝。 -`TRANSLATION_UNAVAILABLE` 拒绝缺失的活跃内置所有者或已注册路由、未配置的凭据、目录中不存在精确的 `deepseek-flash`、派发前所有者/模型列表/官方凭据引用变化、提供方卸载,以及准备后的调用推理强度不是 `off` 的情况。原生 `finish` 的原因为 `error` 时映射为 `TRANSLATION_REQUEST_FAILED`;缺失 finish 或其他非 `stop` 原因映射为 `TRANSLATION_INVALID_RESPONSE`。 +`TRANSLATION_UNAVAILABLE` 拒绝缺失的活跃内置所有者或已注册路由、未配置的凭据、目录中不存在精确的 `deepseek-flash`、派发前所有者、模型配置(官方路由的 `models` 或账号路由的 `modelOverrides`)或官方凭据引用变化、提供方卸载,以及准备后的调用推理强度不是 `off` 的情况。原生 `finish` 的原因为 `error` 时映射为 `TRANSLATION_REQUEST_FAILED`;缺失 finish 或其他非 `stop` 原因映射为 `TRANSLATION_INVALID_RESPONSE`。 Google 在 form POST 请求体中接收原文,Bing 在 JSON POST 请求体中接收原文。两者都是远端服务。免登录端点不保证开发者 API 的可用性或免费额度。区域连通性须在用户网络上验证。 diff --git a/packages/experimental/translator/README.i18n.yaml b/packages/experimental/translator/README.i18n.yaml index e7deacf428..bf7639d94d 100644 --- a/packages/experimental/translator/README.i18n.yaml +++ b/packages/experimental/translator/README.i18n.yaml @@ -18,8 +18,8 @@ en: a8a0b2481c0966c3 zh: aedb4a655c2c96c2 /deepseek-ai-dsh-experimental-translator/understand-the-implementation: - en: 8fa99924e5a2993e - zh: 269b0e9546109251 + en: 26bdde8357a1bb43 + zh: 727c36c494dc644a /deepseek-ai-dsh-experimental-translator/further-exploration: en: 17792a9fe5429633 zh: b51e5e3c30b0e14d diff --git a/packages/experimental/translator/README.md b/packages/experimental/translator/README.md index 59d3232e10..4f2e11dfa0 100644 --- a/packages/experimental/translator/README.md +++ b/packages/experimental/translator/README.md @@ -64,7 +64,7 @@ Storage reads validate translation payloads and request/result references. Only `TranslationError.code` distinguishes input limits, HTTP failures, response limits, invalid provider responses, transport failures, missing durable Sessions, storage failures, unavailable native routes, and the service's `TRANSLATION_TIMEOUT` deadline. `TRANSLATION_SESSION_INACTIVE` rejects an uncached inactive Session before any provider call. 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. Queued cancellation rejects promptly; unloading aborts accepted requests and waits for their underlying storage and network work to settle. Input limits apply in both `resolve()` and `translate()`. -`TRANSLATION_UNAVAILABLE` rejects a missing active built-in owner or registered route, unconfigured credentials, absent exact `deepseek-flash` catalog membership, an owner/model-list/official credential-reference change before dispatch, provider withdrawal, or a prepared call whose reasoning effort is not `off`. Eligibility and thinking checks run before dispatch, with eligibility checked again after request durability. A native `finish` with reason `error` maps to `TRANSLATION_REQUEST_FAILED`; a missing or other non-`stop` finish maps to `TRANSLATION_INVALID_RESPONSE`. +`TRANSLATION_UNAVAILABLE` rejects a missing active built-in owner or registered route, unconfigured credentials, absent exact `deepseek-flash` catalog membership, an owner, model-configuration (Official `models` or Account `modelOverrides`), or Official credential-reference change before dispatch, provider withdrawal, or a prepared call whose reasoning effort is not `off`. Eligibility and thinking checks run before dispatch, with eligibility checked again after request durability. A native `finish` with reason `error` maps to `TRANSLATION_REQUEST_FAILED`; a missing or other non-`stop` finish maps to `TRANSLATION_INVALID_RESPONSE`. diff --git a/packages/experimental/translator/README.zh.md b/packages/experimental/translator/README.zh.md index 06d84b91ae..1ad656e3f0 100644 --- a/packages/experimental/translator/README.zh.md +++ b/packages/experimental/translator/README.zh.md @@ -64,7 +64,7 @@ Google 接收使用 `gtx` 客户端的表单 POST 请求;Bing 通过 Microsoft `TranslationError.code` 区分输入限制、HTTP 失败、响应限制、无效提供方响应、传输失败、缺少持久 Session、存储失败、不可用的原生路由以及服务自身的 `TRANSLATION_TIMEOUT` 超时。未激活 Session 缓存未命中时,在调用任何提供方之前以 `TRANSLATION_SESSION_INACTIVE` 拒绝。诊断不包含提交的文本或提供者错误正文。`translate()` 始终返回 Promise,准入或提供者失败都会使其拒绝。调用者取消和服务卸载保留原始中止原因,包括调用者自身的超时原因。排队期间取消会立即返回拒绝;卸载会取消已接纳的请求,并等待底层存储和网络工作结束。`resolve()` 和 `translate()` 都执行输入限制。 -`TRANSLATION_UNAVAILABLE` 拒绝缺失的活跃内置所有者或已注册路由、未配置的凭据、目录中不存在精确的 `deepseek-flash`、派发前所有者/模型列表/官方凭据引用变化、提供方卸载,以及准备后的调用推理强度不是 `off` 的情况。资格与思考检查在派发前执行,请求持久化之后会再次检查资格。原生 `finish` 的原因为 `error` 时映射为 `TRANSLATION_REQUEST_FAILED`;缺失 finish 或其他非 `stop` 原因映射为 `TRANSLATION_INVALID_RESPONSE`。 +`TRANSLATION_UNAVAILABLE` 拒绝缺失的活跃内置所有者或已注册路由、未配置的凭据、目录中不存在精确的 `deepseek-flash`、派发前所有者、模型配置(官方路由的 `models` 或账号路由的 `modelOverrides`)或官方凭据引用变化、提供方卸载,以及准备后的调用推理强度不是 `off` 的情况。资格与思考检查在派发前执行,请求持久化之后会再次检查资格。原生 `finish` 的原因为 `error` 时映射为 `TRANSLATION_REQUEST_FAILED`;缺失 finish 或其他非 `stop` 原因映射为 `TRANSLATION_INVALID_RESPONSE`。 diff --git a/packages/experimental/translator/src/deepseek.ts b/packages/experimental/translator/src/deepseek.ts index 788d6a64e3..a63da716a2 100644 --- a/packages/experimental/translator/src/deepseek.ts +++ b/packages/experimental/translator/src/deepseek.ts @@ -4,7 +4,7 @@ import type {} from '@deepseek-ai/cordis-plugin-loader' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { BlockAssembler, ReasoningEffortId, type LlmRuntime, type GenerateOptions, type RequestUserInput, type FinishReason, type TextBlock } from '@deepseek-ai/dsh-llm' -import type { Config as SharedNativeConfig } from '@deepseek-ai/dsh-llm-deepseek' +import type { Config as AccountNativeConfig } from '@deepseek-ai/dsh-llm-deepseek-account' import type { Config as OfficialNativeConfig } from '@deepseek-ai/dsh-llm-deepseek-api-key' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { deepFreeze } from '@deepseek-ai/dsh-util-values' @@ -19,10 +19,16 @@ const OWNER_PACKAGES = { 'deepseek-account': '@deepseek-ai/dsh-llm-deepseek-acco interface NativeOwnerFields { llm: LlmRuntime; fiber: Fiber } type NativeOwner = NativeOwnerFields & ( - | { provider: 'deepseek-account'; config: SharedNativeConfig } + | { provider: 'deepseek-account'; config: AccountNativeConfig } | { provider: 'deepseek-official'; config: OfficialNativeConfig } ) -type EligibleOwner = NativeOwner & { models: ReturnType; apiKeyRef?: string } +/** Configured model input of an owner: Account overrides over its listing, or the Official catalog. */ +type ModelConfig = ReturnType | ReturnType +type EligibleOwner = NativeOwner & { modelConfig: ModelConfig; apiKeyRef?: string } + +function modelConfigOf(owner: NativeOwner): ModelConfig { + return owner.provider === 'deepseek-account' ? owner.config.modelOverrides.get() : owner.config.models.get() +} /** * Identify fixed native translation semantics without inspecting credentials or a model catalog. @@ -66,14 +72,14 @@ function nativeOwner(ctx: Context, provider: PaidTranslationProvider): NativeOwn const entry = [...loader.entries()].find(row => row.options.id === directory.settingsNs && row.options.name === OWNER_PACKAGES[provider]) if (entry?.fiber?.state !== FiberState.ACTIVE) return undefined return provider === 'deepseek-account' - ? { llm, fiber: entry.fiber, provider, config: entry.fiber.config as SharedNativeConfig } + ? { llm, fiber: entry.fiber, provider, config: entry.fiber.config as AccountNativeConfig } : { llm, fiber: entry.fiber, provider, config: entry.fiber.config as OfficialNativeConfig } } function unchanged(ctx: Context, provider: PaidTranslationProvider, previous: EligibleOwner): NativeOwner | undefined { const current = nativeOwner(ctx, provider) if (current === undefined || current.fiber !== previous.fiber - || current.config.models.get() !== previous.models + || modelConfigOf(current) !== previous.modelConfig || (current.provider === 'deepseek-official' && current.config.apiKeyEnv.get() !== previous.apiKeyRef)) return undefined return current } @@ -83,7 +89,7 @@ async function eligible(ctx: Context, provider: PaidTranslationProvider, signal: signal.throwIfAborted() const owner = nativeOwner(ctx, provider) if (owner === undefined) return undefined - const models = owner.config.models.get() + const modelConfig = modelConfigOf(owner) const apiKeyRef = owner.provider === 'deepseek-official' ? owner.config.apiKeyEnv.get() : undefined if (apiKeyRef !== undefined) { const credentials = ctx.get('credentials') @@ -94,7 +100,7 @@ async function eligible(ctx: Context, provider: PaidTranslationProvider, signal: const catalog = await awaitMetadata(() => owner.llm.listModels(provider), signal, track) signal.throwIfAborted() if (!catalog.some(model => model.id === MODEL)) return undefined - const accepted = { ...owner, models, ...apiKeyRef === undefined ? {} : { apiKeyRef } } + const accepted = { ...owner, modelConfig, ...apiKeyRef === undefined ? {} : { apiKeyRef } } return unchanged(ctx, provider, accepted) === undefined ? undefined : accepted } diff --git a/packages/experimental/translator/tests/deepseek.spec.ts b/packages/experimental/translator/tests/deepseek.spec.ts index 276a56b2e9..5ce3434528 100644 --- a/packages/experimental/translator/tests/deepseek.spec.ts +++ b/packages/experimental/translator/tests/deepseek.spec.ts @@ -1,6 +1,7 @@ /** Paid selection, independent native requests, audit-before-I/O and cancellation. */ import { Context, FiberState } from '@deepseek-ai/cordis' import { ReasoningEffortId, createUserMessage, createAssistantMessage, type PreparedLlmCall } from '@deepseek-ai/dsh-llm' +import { Config as AccountConfig } from '@deepseek-ai/dsh-llm-deepseek-account' import { Config as OfficialConfig } from '@deepseek-ai/dsh-llm-deepseek-api-key' import { pluginRecordOf, SessionId } from '@deepseek-ai/dsh-session' import { TimeoutReason } from '@deepseek-ai/dsh-timeout' @@ -282,6 +283,24 @@ describe('native Flash translation', () => { expect(b.session?.snapshotEvents()).toEqual([]) }) + it('rejects an Account model-override change during async preparation before audit or dispatch', async () => { + const b = await paidFixture(cleanups), entered = Promise.withResolvers(), release = Promise.withResolvers() + const original = b.ctx.llm.prepareCall.bind(b.ctx.llm) + vi.spyOn(b.ctx.llm, 'prepareCall').mockImplementationOnce(async (config, signal) => { + const call = await original(config, signal) + entered.resolve(undefined); await release.promise; return call + }) + const rejected = expect(b.translate('deepseek-account')).rejects.toMatchObject({ code: 'TRANSLATION_UNAVAILABLE' }) + await entered.promise + const owner = [...b.ctx.loader.entries()].find(entry => entry.options.id === 'account-custom-id')! + const config = owner.fiber!.config as ReturnType + config.modelOverrides = AccountConfig({ modelOverrides: { 'deepseek-flash': { maxTokens: 4096 } } }).modelOverrides + release.resolve(undefined) + await rejected + expect(b.requests).toEqual([]) + expect(b.session?.snapshotEvents()).toEqual([]) + }) + it.each(['withdrawn', 'catalog', 'credential'])('rejects a native route %s by an audit observer before dispatch', async (change) => { const b = await paidFixture(cleanups) const owner = [...b.ctx.loader.entries()].find(entry => entry.options.id === 'official-custom-id')! diff --git a/packages/experimental/translator/tests/paid-fixture.ts b/packages/experimental/translator/tests/paid-fixture.ts index f6ea7d6577..87d75cf32c 100644 --- a/packages/experimental/translator/tests/paid-fixture.ts +++ b/packages/experimental/translator/tests/paid-fixture.ts @@ -49,6 +49,7 @@ export function sse(events: Record[]): string { export interface FixtureOptions { translator?: Partial officialModels?: { id: string; name: string }[] + /** Entries of the account `GET /models` listing. */ accountModels?: { id: string; name: string }[] officialName?: string withCredentials?: boolean @@ -82,6 +83,12 @@ export async function paidFixture(cleanups: Array<() => Promise>, options: let auditId = SessionId('paid-translation') const server = createServer((request, response) => { const work = (async () => { + if (request.method === 'GET' && request.url === '/models') { + const listed = options.accountModels ?? [{ id: 'deepseek-flash', name: 'Another arbitrary Flash version' }] + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ object: 'list', data: listed.map(model => ({ ...model, api_capabilities: { anthropic_messages: {} } })) })) + return + } const chunks: Buffer[] = [] for await (const chunk of request) { if (!(chunk instanceof Buffer)) throw new Error('Expected HTTP body bytes') @@ -125,9 +132,7 @@ export async function paidFixture(cleanups: Array<() => Promise>, options: { id: 'official-custom-id', name: options.officialName ?? '@deepseek-ai/dsh-llm-deepseek-api-key', disabled: !withNativeOwners, config: { baseURL, apiKeyEnv: 'PAID_TRANSLATION_FIXTURE_KEY', models: options.officialModels ?? [{ id: 'deepseek-flash', name: 'Flash version-independent name' }], } }, - { id: 'account-custom-id', name: '@deepseek-ai/dsh-llm-deepseek-account', disabled: !withNativeOwners, config: { - baseURL, models: options.accountModels ?? [{ id: 'deepseek-flash', name: 'Another arbitrary Flash version' }], - } }, + { id: 'account-custom-id', name: '@deepseek-ai/dsh-llm-deepseek-account', disabled: !withNativeOwners, config: { baseURL } }, { id: 'translator', name: '@deepseek-ai/dsh-experimental-translator', config: translator }, ...options.withSessionQuery === true ? [ { id: 'query', name: '@deepseek-ai/dsh-session-query-sqlite', config: { path: join(root, 'query.sqlite'), openAt: 'never' } }, diff --git a/packages/experimental/translator/tsconfig.json b/packages/experimental/translator/tsconfig.json index aedcfa3857..3317692e47 100644 --- a/packages/experimental/translator/tsconfig.json +++ b/packages/experimental/translator/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../llm/llm-deepseek-api-key" }, + { + "path": "../../llm/llm-deepseek-account" + }, { "path": "../../credentials/credentials" },