From 3e4119ebdb331ebca0a78676a3967ed3db0f3734 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:12:01 +0800 Subject: [PATCH] fix(account): expire account credentials on inference HTTP 401 --- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 3 ++- packages/llm/llm-deepseek/src/transport.ts | 3 +-- packages/llm/llm-deepseek/tests/adapter.spec.ts | 15 ++++++++++----- 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 765dd5a54a..7381689090 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 8019a8be1f0c8e1f00ae5573e350c3a7eaf1c721 -README.zh.md: 86855c49ebeb9fd3c3ca578d52eefe7b6e4c8f92 +README.md: 8d8e28faddf91026c249dbbfb0cfc183c815ae9d +README.zh.md: cee5d30a17a39d9e0613747db4b1a0035bef9c53 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 8019a8be1f..8d8e28fadd 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -86,7 +86,7 @@ Messages sends text, thinking, tool calls, and tool results as content blocks, r `deepseek-official` resolves only its configured API-key reference. `deepseek-account` resolves only the stored grant from the [account provider](../../credentials/deepseek-account-platform/README.md), whose allowed `inferenceOrigin` defaults to `https://api.deepseek.com`. Neither route falls back to the other. Signing out removes the account grant and preserves API keys. -Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. HTTP 401 with the provider message `Authentication Fails (invalid dsh token)` becomes `ACCOUNT_TOKEN_INVALID`. On the account route, it removes the request token only if it still matches the stored login, then publishes account expiry and UI sign-out. Generic authentication errors and API-key routes do not remove account credentials. +Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. HTTP 401 on a request authenticated with an account token becomes `ACCOUNT_TOKEN_INVALID`, regardless of the response body. It removes that request token only if it still matches the stored login, then publishes account expiry and UI sign-out. API-key routes and other HTTP statuses do not remove account credentials. ### Streaming with thinking and images diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 86855c49eb..cee5d30a17 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -86,7 +86,7 @@ Messages 以内容块发送文本、思考、工具调用和工具结果,以 ` `deepseek-official` 仅解析配置的 API Key 引用。`deepseek-account` 仅解析[账号提供者](../../credentials/deepseek-account-platform/README.zh.md)保存的授权,其允许的 `inferenceOrigin` 默认为 `https://api.deepseek.com`。两条路由均不回退到另一凭证。退出登录删除账号授权,保留 API Key。 -Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。HTTP 401 且提供方消息为 `Authentication Fails (invalid dsh token)` 时归类为 `ACCOUNT_TOKEN_INVALID`。账号路由仅在请求 token 仍匹配已存登录凭据时删除它,并发布账号过期状态和 UI 退登通知。普通鉴权错误和 API Key 路由不会删除账号凭据。 +Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。使用账号 token 鉴权的请求返回 HTTP 401 时归类为 `ACCOUNT_TOKEN_INVALID`,不依赖响应体内容。仅当该请求的 token 仍匹配已存登录凭据时才删除它,并发布账号过期状态和 UI 退登通知。API Key 路由和其他 HTTP 状态码不会删除账号凭据。 ### 带 thinking 与图片的流式调用 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 41a2cf4a96..2010a72dcf 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -139,7 +139,8 @@ export class DeepSeekAdapter extends LlmAdapter { if (await files.retry(detail)) continue const failure = providerError(raw, response.status, response.headers) const message = files.errorMessage(response.status, failure.message, detail) - throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) + const code = accountToken !== undefined && response.status === 401 ? 'ACCOUNT_TOKEN_INVALID' : failure.code + throw new LlmError(message, code, { ...failure.failure, cause: new Error(text) }) } await extensions.accept() if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/src/transport.ts b/packages/llm/llm-deepseek/src/transport.ts index ddae610178..935e8855df 100644 --- a/packages/llm/llm-deepseek/src/transport.ts +++ b/packages/llm/llm-deepseek/src/transport.ts @@ -26,8 +26,7 @@ export function providerError(raw: unknown, status: number | undefined, headers? const type = typeof error.type === 'string' ? error.type : '' const detail = `${type} ${typeof error.code === 'string' ? error.code : ''} ${message}` let code: string - if (status === 401 && message === 'Authentication Fails (invalid dsh token)') code = 'ACCOUNT_TOKEN_INVALID' - else if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' + if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' else if (isQuotaExceededError(detail) || status === 402) code = 'QUOTA' else if (status === 429 || type === 'rate_limit_error') code = 'RATE_LIMIT' else if (isContextWindowExceededError(detail)) code = 'CONTEXT_WINDOW_EXCEEDED' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index b051aedda6..c03a5f6d65 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -277,16 +277,21 @@ describe('Cordis provider composition', () => { return { ctx, http } } - it.each(['deepseek-account', 'deepseek-official'])('reports the rejected account token only for %s', async (provider) => { + it.each(['deepseek-account', 'deepseek-official'].flatMap(provider => [ + { provider, body: JSON.stringify({ error: { type: 'authentication_error', message: 'API key is invalid' } }) }, + { provider, body: JSON.stringify({ error: { message: 'Authentication Fails (invalid dsh token)' } }) }, + { provider, body: 'Unauthorized' }, + { provider, body: '' }, + ]))('handles $provider HTTP 401 independently of the response body ($body)', async ({ provider, body }) => { const { ctx } = await boot((response) => { response.writeHead(401, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'Authentication Fails (invalid dsh token)' } })) + response.end(body) }) const rejectToken = vi.fn(async (_token: string) => {}) ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', rejectToken: (token: string): Promise => rejectToken(token) } as DeepSeekAccount) expect((await chunks(ctx.llm.stream(options({ provider })))).at(-1)).toMatchObject({ - type: 'finish', reason: { kind: 'error', failure: { code: 'ACCOUNT_TOKEN_INVALID' } }, + type: 'finish', reason: { kind: 'error', failure: { code: provider === 'deepseek-account' ? 'ACCOUNT_TOKEN_INVALID' : 'AUTH' } }, }) expect(rejectToken.mock.calls).toEqual(provider === 'deepseek-account' ? [['fixture-token']] : []) }) @@ -318,9 +323,9 @@ describe('Cordis provider composition', () => { }) }) - it('does not remove account credentials for a generic authentication error', async () => { + it('does not remove account credentials for HTTP 403', async () => { const { ctx } = await boot((response) => { - response.writeHead(401) + response.writeHead(403) response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'API key is invalid' } })) }) const rejectToken = vi.fn(async (_token: string) => {})