From 17825ee8c0e03ff79d2af95a9d43614635f0bdb3 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:13:01 +0800 Subject: [PATCH 01/30] feat(agent,llm-deepseek,ui-settings-account): separate account routing and confirm sign-out --- ...026-09-14-deepseek-account-login.i18n.yaml | 4 +- .../2026-09-14-deepseek-account-login.md | 2 + .../2026-09-14-deepseek-account-login.zh.md | 2 + ...6-09-17-account-provider-signout.i18n.yaml | 6 + .../2026-09-17-account-provider-signout.md | 27 +++ .../2026-09-17-account-provider-signout.zh.md | 27 +++ apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 25 +- docs/event-producer-consumer.zh.md | 25 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 4 + docs/subsystems/core.zh.md | 4 + docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 21 +- docs/subsystems/credentials.zh.md | 21 +- .../api/account-controller/README.i18n.yaml | 4 +- packages/api/account-controller/README.md | 2 + packages/api/account-controller/README.zh.md | 2 + packages/api/account-controller/package.json | 2 + packages/api/account-controller/src/index.ts | 11 +- .../tests/signout-impact.spec.ts | 23 ++ packages/api/account-controller/tsconfig.json | 3 + .../tests/control-jobs.host.spec.ts | 1 + .../bundle/headless/tests/headless.spec.ts | 1 + .../ui-chat/src/client/chat/MessageItem.tsx | 4 +- .../client/conversation-nodes/turn-error.ts | 13 +- packages/client/ui-chat/src/client/locale.ts | 6 + ...nversation-node-definitions.client.spec.ts | 10 + .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 2 + .../client/ui-model-selection/README.zh.md | 2 + .../ui-model-selection/src/client/index.ts | 2 + .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 + .../client/ui-settings-account/README.zh.md | 2 + .../src/client/AccountMenu.tsx | 12 +- .../src/client/AccountSection.tsx | 2 + .../src/client/SignOutDialog.tsx | 41 ++++ .../ui-settings-account/src/client/index.ts | 5 + .../ui-settings-account/src/client/locales.ts | 8 +- .../tests/account.client.spec.tsx | 7 +- .../tests/expected/sign-out-idle-en.txt | 1 + .../tests/expected/sign-out-idle-zh.txt | 1 + .../tests/expected/sign-out-running-en.txt | 1 + .../tests/expected/sign-out-running-zh.txt | 1 + .../tests/sign-out-dialog.client.spec.tsx | 45 ++++ .../time-context/tests/time-context.spec.ts | 1 + .../tmux-context/tests/tmux-context.spec.ts | 1 + packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 + packages/core/agent-loop/README.zh.md | 2 + packages/core/agent-loop/src/agent.ts | 8 +- packages/core/agent-loop/tests/inbox.spec.ts | 1 + packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 + packages/core/agent/README.zh.md | 2 + packages/core/agent/src/runtime-types.ts | 2 + packages/core/agent/tests/agent.spec.ts | 1 + .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 + .../deepseek-account-platform/README.zh.md | 2 + .../deepseek-account-platform/src/index.ts | 2 + .../tests/account.spec.ts | 12 + .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 + .../credentials/deepseek-account/README.zh.md | 2 + .../credentials/deepseek-account/src/index.ts | 8 +- .../tool-present/tests/present.spec.ts | 1 + .../tests/resources.spec.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 +- .../tests/command-feedback.spec.ts | 1 + .../tests/loader-composition.spec.ts | 1 + .../tests/tools.spec.ts | 1 + .../command-goal/tests/command-goal.spec.ts | 1 + packages/goal/goal/tests/goal.spec.ts | 1 + packages/goal/goal/tests/projection.spec.ts | 1 + .../goal/tool-goal/tests/tool-goal.spec.ts | 1 + packages/jobs/jobs-local/tests/jobs.spec.ts | 1 + 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/package.json | 6 +- packages/llm/llm-deepseek/src/adapter.ts | 4 +- packages/llm/llm-deepseek/src/common/types.ts | 8 +- packages/llm/llm-deepseek/src/index.ts | 26 ++- .../src/protocols/chat-completions/adapter.ts | 5 +- .../src/protocols/messages/adapter.ts | 9 +- .../tests/account-routing.spec.ts | 216 ++++++++++++++++++ .../llm/llm-deepseek/tests/adapter.spec.ts | 28 +-- packages/llm/llm-deepseek/tsconfig.json | 3 + .../schedule/schedule/tests/runtime.spec.ts | 1 + .../schedule/schedule/tests/tools.spec.ts | 1 + .../tests/loader-composition.spec.ts | 1 + .../tool-bash-persistent/tests/tools.spec.ts | 1 + .../tests/loader-composition.spec.ts | 1 + .../tool-pwsh-persistent/tests/tools.spec.ts | 1 + .../skill/tool-skill/tests/tool-skill.spec.ts | 2 + .../terminal-bash/tests/index.spec.ts | 3 + .../terminal-bash/tests/local.spec.ts | 1 + .../terminal/terminal/tests/service.spec.ts | 1 + .../tests/loader-composition.spec.ts | 1 + .../tool-terminal/tests/tools.spec.ts | 1 + .../tests/loader-composition.spec.ts | 1 + pnpm-lock.yaml | 3 + .../expected/account-provider-signout.json | 11 + .../tests/test_account_provider_snapshot.py | 35 +++ scripts/gen-cordis-catalog.ts | 1 + .../account-signout.mjs | 11 + .../cordis.snapshot.yml | 1 + .../sdk/account-provider-signout/cordis.yml | 33 +++ .../notifications.expected.jsonl | 15 ++ .../result.expected.json | 1 + .../account-provider-signout/session.v3.jsonl | 17 ++ .../sdk/account-provider-signout/snapshot.yml | 10 + .../system-prompt.expected.md | 1 + .../tool-schemas.expected.json | 1 + 123 files changed, 869 insertions(+), 110 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-account-provider-signout.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-account-provider-signout.zh.md create mode 100644 packages/api/account-controller/tests/signout-impact.spec.ts create mode 100644 packages/client/ui-settings-account/src/client/SignOutDialog.tsx create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt create mode 100644 packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx create mode 100644 packages/llm/llm-deepseek/tests/account-routing.spec.ts create mode 100644 python/sdk/tests/expected/account-provider-signout.json create mode 100644 python/sdk/tests/test_account_provider_snapshot.py create mode 100644 snapshots/sdk/account-provider-signout/account-signout.mjs create mode 100644 snapshots/sdk/account-provider-signout/cordis.snapshot.yml create mode 100644 snapshots/sdk/account-provider-signout/cordis.yml create mode 100644 snapshots/sdk/account-provider-signout/notifications.expected.jsonl create mode 100644 snapshots/sdk/account-provider-signout/result.expected.json create mode 100644 snapshots/sdk/account-provider-signout/session.v3.jsonl create mode 100644 snapshots/sdk/account-provider-signout/snapshot.yml create mode 120000 snapshots/sdk/account-provider-signout/system-prompt.expected.md create mode 120000 snapshots/sdk/account-provider-signout/tool-schemas.expected.json diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml index e829ccaf12..f6400660de 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.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 .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md -2026-09-14-deepseek-account-login.md: ce6fae5854117b8ff1e52ce415559beb1e576d50 -2026-09-14-deepseek-account-login.zh.md: 2422750d15cf8a85a816902c9a47e35741351cdd +2026-09-14-deepseek-account-login.md: a38a275b07d0f25a271b86987025fca10738f282 +2026-09-14-deepseek-account-login.zh.md: b58263c07f593b2777719c0b26a6cad8dfad8fae diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md index ce6fae5854..a38a275b07 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md @@ -63,3 +63,5 @@ Exchange user data supplies the first profile read after credential commit, avoi The embedded Platform document stays hidden during loading because native child views cover renderer overlays. Only the current document may become visible after loading; returning or signing out invalidates pending visibility changes. Native ownership also ends when the application document reloads or is replaced, its renderer terminates, or its window closes. React effect cleanup alone is insufficient because document teardown may never execute it. Same-document and subframe navigation preserve the view. Separate accountRequestHeaders route account data and embedded Platform traffic independently of authorization and logout. Cookie overrides merge by name, retaining deployment authentication. Host passes the resolved headers over private process IPC; Electron injects them only at the configured origin and omits them from bootstrap. + +Account inference route selection and active-task cancellation are defined by the [provider sign-out decision](2026-09-17-account-provider-signout.md). diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md index 2422750d15..b58263c07f 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md @@ -63,3 +63,5 @@ exchange 的 user 数据在凭证提交后供首次资料读取使用,展示 内嵌 Platform 文档在加载期间保持隐藏,因为原生子视图会覆盖渲染层浮层。加载完成后仅当前文档可显示;返回或退登会使待执行的显示操作失效。应用文档刷新或替换、其渲染进程终止或窗口关闭时,原生视图的所有权也随之结束。仅依赖 React effect 清理不足以保证释放,因为文档销毁时可能不执行它。同文档导航和子框架导航保留视图。 独立的 accountRequestHeaders 将账号资料及内嵌 Platform 流量与授权、退登分开路由。Cookie 覆盖按名称合并,保留部署认证。Host 通过私有进程 IPC 传递合并后的请求头;Electron 仅在配置来源注入,并从 bootstrap 排除。 + +账号推理路由选择及运行任务取消由[提供方退登决策](2026-09-17-account-provider-signout.zh.md)定义。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.i18n.yaml new file mode 100644 index 0000000000..508dec336c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md +2026-09-17-account-provider-signout.md: dd9930a7b36fd93c18febb8cca506e59bb605797 +2026-09-17-account-provider-signout.zh.md: e706a1bacc3c3ef2c3b29912fa85da59b02d8d5e diff --git a/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md new file mode 100644 index 0000000000..dd9930a7b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md @@ -0,0 +1,27 @@ +# Agent Note: Account provider and active request cancellation + +Status: implemented + +English | [中文](2026-09-17-account-provider-signout.zh.md) + +## Problem + +Selecting credentials from login state changes the billing identity of an existing provider route. Sign-out also needs to stop account-dependent work without classifying requests from settings that may have changed while a request or tool is still running. + +## Decision + +`deepseek-official` and `deepseek-account` are independent routes sharing the DeepSeek protocol implementation and validated connection settings. Each adapter has exactly one credential resolver and authentication header mode. Neither route falls back to the other credential. The account service retains the origin and issuer validation defined by the [login decision](2026-09-14-deepseek-account-login.md). + +The Agent running phase owns `activeProvider`, the final provider returned by request preparation. The value remains through tools and retry preparation, changes when the next request binds, and clears before `turn/end`. It is undefined during a turn’s first preparation. A missing account credential still rejects the first request before transport. Token resolution checks local sign-out in progress; no login-level signal, credential lease, or synchronization protocol covers the short token-read/send interval. + +Successful local sign-out publishes an account event. The DeepSeek plugin enumerates the live Agent registry and cancels matching account routes through `Agent.cancel`, preserving inboxes. Each registered child is classified independently; existing parent-owned cancellation still applies to children whose lifetime is attached to a cancelled parent. Independent API-key agents continue. The existing HTTP signal carries cancellation to the transport. + +## Alternatives considered + +A historical set of providers wrongly cancels a turn after it switches to an API-key route. Current settings misclassify an older in-flight request. A module-global map duplicates Agent lifecycle ownership, while making the account service manage every provider reverses the service dependency. A login-level cancellation signal duplicates the existing Agent and HTTP cancellation chain. + +## Consequences + +Model selection exposes both routes, and choosing an account route requires login even when an API key exists. The account route shares its model catalog and connection settings with the API-key route. Cancellation preserves queued input without waking it automatically. The recorded hook reason produces a localized conversation notice without changing durable event types. + +Behavior tests cover credential separation, first preparation, tool-stage cancellation, and route replacement. The SDK account-provider-signout scenario boots the shipped profile and records interrupted output plus the cancellation cause. diff --git a/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.zh.md b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.zh.md new file mode 100644 index 0000000000..e706a1bacc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 账号提供方与当前请求取消 + +Status: implemented + +[English](2026-09-17-account-provider-signout.md) | 中文 + +## Problem + +根据登录态选择凭证会改变现有提供方路由的计费身份。退登也需要停止依赖账号的任务,不能根据请求或工具运行期间可能已经改变的设置判断归属。 + +## Decision + +`deepseek-official` 和 `deepseek-account` 是独立路由,共享 DeepSeek 协议实现及已校验的连接设置。每个适配器仅有一个凭证解析器和认证请求头模式。两条路由均不回退到另一凭证。账号服务保留[登录决策](2026-09-14-deepseek-account-login.zh.md)定义的来源及签发方校验。 + +Agent 运行阶段拥有 `activeProvider`,其值是请求准备返回的最终提供方。该值在工具执行及重试准备期间保留,在绑定下一请求时改变,在 `turn/end` 前清除。轮次的首请求准备期间该值为 undefined。账号凭证缺失仍在首请求进入传输前拒绝请求。Token 解析检查本地退登是否进行中;不为短暂的读取 token 到发送间隙引入登录级信号、凭证租约或同步协议。 + +本地退登成功后发布账号事件。DeepSeek 插件遍历运行中的 Agent 注册表,通过 `Agent.cancel` 取消匹配的账号路由并保留收件箱。每个已注册子代理独立分类;生命周期绑定到已取消父代理的子代理仍受现有父级取消规则约束。独立的 API Key 代理继续运行。现有 HTTP 信号将取消传递到传输层。 + +## Alternatives considered + +历史提供方集合会在轮次切换到 API Key 路由后仍错误取消它。当前设置会误判更早的在途请求。模块级全局映射重复拥有 Agent 生命周期,让账号服务管理所有提供方则反转服务依赖。登录级取消信号重复现有 Agent 与 HTTP 取消链路。 + +## Consequences + +模型选择展示两条路由,即使存在 API Key,选择账号路由仍要求登录。账号路由与 API Key 路由共享模型目录和连接设置。取消保留排队输入,不自动唤醒。已记录的 hook 原因生成本地化对话提示,无须改变持久事件类型。 + +行为测试覆盖凭证分离、首请求准备、工具阶段取消及路由替换。SDK account-provider-signout 场景启动随附 profile,记录被中断的输出和取消原因。 diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index 4c2331ed0b..89936bf4e7 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -25,6 +25,7 @@ try { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 3d9fba9385..d642a66c82 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 docs/architecture.md -architecture.md: 0bbf5ef7f4c0444635178ba0ba85fbb9527d76ec -architecture.zh.md: 0d28dc349fd9c655529bcc43deabc47055e0d394 +architecture.md: 3e651fa4433f0d6bab0ba73cd85929ed45e9ee79 +architecture.zh.md: a30bb804c419f38e3f68a06c252e90e4993b9e42 diff --git a/docs/architecture.md b/docs/architecture.md index 0bbf5ef7f4..3e651fa443 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,7 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). -The loop sends immutable requests while keeping cancellation live. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. +Immutable requests retain cancellation. `Agent.activeProvider` exposes current provider ownership. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0d28dc349f..a30bb804c4 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -116,7 +116,7 @@ turn/end `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 -循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 +循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 `Agent.activeProvider` 暴露当前提供方归属。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7e7a1b2031..3b89b5ced5 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 3565a7f412d523de926d8174832a7097c9921484 -config-catalog.zh.md: 43fd0444878bfe60f73a83c8376ed236b2452f6d +config-catalog.md: 57e2c00c98ab2cf09757d78e93fff042156918b1 +config-catalog.zh.md: f197659787958d902a3069ac889b442dcad46d6b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3565a7f412..57e2c00c98 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3850,7 +3850,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-account-controller` — requires `deepseekAccount` ([`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts)) +- `@deepseek-ai/dsh-api-account-controller` — requires `deepseekAccount` · `agents` ([`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 43fd044487..f197659787 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3852,7 +3852,7 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-account-controller` — 需要 `deepseekAccount` ([`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts)) +- `@deepseek-ai/dsh-api-account-controller` — 需要 `deepseekAccount` · `agents` ([`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 5cfe51c607..72ffc23a6d 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: e43462692addcb7b42e3e35264ed1b9a997c84fd -event-producer-consumer.zh.md: dcf2f84df854e823fbd7b8e6172d373e0e7f10fc +event-producer-consumer.md: 3d5ad12ef85b6b3c4fedde87cea8963f4a20d93b +event-producer-consumer.zh.md: c212611c25f60283f91ef58530121d84092be936 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e43462692a..3d5ad12ef8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,18 +9,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:247`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:365`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:263`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:272`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:395`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:301`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:322`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:339`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -39,6 +39,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | +| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index dcf2f84df8..c212611c25 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,18 +11,18 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:247`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:365`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:263`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:272`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:395`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:301`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:322`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:339`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -41,6 +41,7 @@ | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | +| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index e1fd460b35..23bd8abfb0 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: bba024e8ee4cb89ceac4698bf66f060c7f74dcd6 -core.zh.md: 2e04d5a9a078f3411b2a0d338835ec7d71296f10 +core.md: ad5bb66073bc7c3381739935eff593d49343b851 +core.zh.md: d0ec1aed5f3d504e4cd5e781c3a6927f311cbc7b diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index bba024e8ee..ad5bb66073 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -69,6 +69,8 @@ interface Agent { readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus + /** Latest prepared provider in this turn; retained through tools and retries, absent before preparation and between turns. */ + readonly activeProvider: string | undefined /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context @@ -1253,3 +1255,5 @@ One session committed a different agent preset to its durable log. Consumers inv Source: [`packages/preset/agent-presets/src/types.ts`](../../packages/preset/agent-presets/src/types.ts) + +`Agent.activeProvider` is the latest prepared request’s provider in the current turn. It is undefined before first preparation and between turns, stays unchanged during tools and subsequent preparation, and changes only when the next final request configuration is bound. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 2e04d5a9a0..d0ec1aed5f 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -73,6 +73,8 @@ interface Agent { readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus + /** Latest prepared provider in this turn; retained through tools and retries, absent before preparation and between turns. */ + readonly activeProvider: string | undefined /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context @@ -1263,3 +1265,5 @@ One session committed a different agent preset to its durable log. Consumers inv Source: [`packages/preset/agent-presets/src/types.ts`](../../packages/preset/agent-presets/src/types.ts) + +`Agent.activeProvider` 是当前轮次最近已准备请求的提供方。首请求准备前及轮次之间为 undefined,工具执行和后续请求准备期间保持不变,仅在绑定下一最终请求配置时改变。 diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 8ffc27a0b4..92aca7e81f 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.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 docs/subsystems/credentials.md -credentials.md: f0d64c47cbe886941ed8ad8d3d41304dcc76a493 -credentials.zh.md: c216cc58505f9f300626e5943e479df19537a9fa +credentials.md: a28becab83262ddb8355176b7dbda1e25b4a2d9d +credentials.zh.md: 5a3b84dbe57a592c1109f72f0cc5867014f1bbc3 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index f0d64c47cb..a28becab83 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -301,7 +301,7 @@ abstract startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' abstract cancelSignIn(id: SignInAttemptId): Promise /** - * Remove the local grant while retaining API keys and tasks; the provider revokes it in the background. + * Remove the local grant while retaining API keys; the provider revokes it in the background. * @returns the signed-out state after local removal; remote failures never restore the grant. */ abstract signOut(): Promise @@ -402,6 +402,25 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o ``` Source: [`packages/credentials/credentials/src/types.ts`](../../packages/credentials/credentials/src/types.ts) + + + +### `deepseek-account/*` events + + + +#### `deepseek-account/signed-out` — emit + +Local grant removal has completed. + +```ts cordis-catalog +/** Local grant removal has completed. + * @mode emit + */ +'deepseek-account/signed-out'(): void +``` + +Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts) The account Service Definition exposes getState, getDetails, startSignIn, cancelSignIn, signOut, watch, and Host-only resolveToken. The platform provider implements it with an AuthorizationFlow and a private GrantRecord. AccountView distinguishes stored presence from server validation; attempt IDs bind cancellation to one local flow. See [the account package](../../packages/credentials/deepseek-account/README.md). diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index c216cc5850..5a3b84dbe5 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -301,7 +301,7 @@ abstract startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' abstract cancelSignIn(id: SignInAttemptId): Promise /** - * Remove the local grant while retaining API keys and tasks; the provider revokes it in the background. + * Remove the local grant while retaining API keys; the provider revokes it in the background. * @returns the signed-out state after local removal; remote failures never restore the grant. */ abstract signOut(): Promise @@ -402,6 +402,25 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o ``` Source: [`packages/credentials/credentials/src/types.ts`](../../packages/credentials/credentials/src/types.ts) + + + +### `deepseek-account/*` events + + + +#### `deepseek-account/signed-out` — emit + +Local grant removal has completed. + +```ts cordis-catalog +/** Local grant removal has completed. + * @mode emit + */ +'deepseek-account/signed-out'(): void +``` + +Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts) 账号服务定义提供 getState、getDetails、startSignIn、cancelSignIn、signOut、watch 及仅限 Host 的 resolveToken。平台提供者使用 AuthorizationFlow 和私有 GrantRecord 实现这些操作。AccountView 区分本地存在与服务器验证;尝试 ID 将取消绑定到单次本地流程。参见[账号包](../../packages/credentials/deepseek-account/README.zh.md)。 diff --git a/packages/api/account-controller/README.i18n.yaml b/packages/api/account-controller/README.i18n.yaml index 6a1ad9e167..236a988cda 100644 --- a/packages/api/account-controller/README.i18n.yaml +++ b/packages/api/account-controller/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/api/account-controller/README.md -README.md: eb6ef47f91d372a5a7ff895f343ff4a3dcfceb8d -README.zh.md: 3ab666d576e8c1d10b7f439f818a45d8a4ae23b6 +README.md: e1d0769e5e76541355f6b725627a833ce399fa2b +README.zh.md: cbc167df735d692c5395bf167c777f1bf17fe8a5 diff --git a/packages/api/account-controller/README.md b/packages/api/account-controller/README.md index eb6ef47f91..e1d0769e5e 100644 --- a/packages/api/account-controller/README.md +++ b/packages/api/account-controller/README.md @@ -22,6 +22,8 @@ Account screens use authenticated Remote commands and a snapshot stream. The con The account namespace exposes getState, getProfile / getBalance, startSignIn, cancelSignIn, signOut, and watch. watch emits an initial complete state and subsequent complete states; disconnecting stops observation, not the login attempt. Cancellation names the attempt ID so a stale screen cannot cancel a newer login. +hasRunningAccountTasks reads the agents registry and reports whether any active prepared provider is deepseek-account, including tool execution and retries. It excludes unprepared turns and API-key providers. The result describes the query instant; sign-out independently cancels matching agents when credentials are removed. + ## Understand the implementation diff --git a/packages/api/account-controller/README.zh.md b/packages/api/account-controller/README.zh.md index 3ab666d576..cbc167df73 100644 --- a/packages/api/account-controller/README.zh.md +++ b/packages/api/account-controller/README.zh.md @@ -22,6 +22,8 @@ kind: "package-reference" account 命名空间提供 getState、getProfile / getBalance、startSignIn、cancelSignIn、signOut 和 watch。watch 先发送完整初始状态,随后发送完整状态变化;断开连接只停止观察,不取消登录。取消操作必须指定尝试 ID,防止旧页面取消新登录。 +hasRunningAccountTasks 读取 agents 注册表,判断是否有任务实际使用 deepseek-account,包括工具执行和重试阶段;未完成请求准备的轮次和 API key provider 不计入。结果反映查询时刻;移除凭据时,退登操作会独立取消匹配的 Agent。 + ## 理解实现 diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json index 3835f5d9be..42db2ba883 100644 --- a/packages/api/account-controller/package.json +++ b/packages/api/account-controller/package.json @@ -48,11 +48,13 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-deepseek-account": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-deepseek-account": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" } diff --git a/packages/api/account-controller/src/index.ts b/packages/api/account-controller/src/index.ts index 685412244d..3984b3b291 100644 --- a/packages/api/account-controller/src/index.ts +++ b/packages/api/account-controller/src/index.ts @@ -2,12 +2,13 @@ import { Context } from '@deepseek-ai/cordis' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-deepseek-account' +import type {} from '@deepseek-ai/dsh-agent' import type { AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types' import type { AccountView, SignInAttemptId } from './types.ts' /** Account commands and reconnect-safe state stream. */ export class AccountController extends TypertRemoteService { - static inject = ['deepseekAccount'] + static inject = ['deepseekAccount', 'agents'] /** @param ctx - Host with the account provider mounted. */ constructor(ctx: Context) { super(ctx, 'accountController', { namespace: 'account' }) } /** @@ -44,6 +45,14 @@ export class AccountController extends TypertRemoteService { */ @Remote cancelSignIn(attemptId: SignInAttemptId): Promise { return this.ctx.deepseekAccount.cancelSignIn(attemptId) } + /** + * Inspect prepared providers across running tasks, including tools and retries. + * @returns whether any task currently uses the account token; unprepared turns are excluded. + */ + @Remote + hasRunningAccountTasks(): boolean { + return this.ctx.agents.list().some(agent => agent.activeProvider === 'deepseek-account') + } /** * Remove the local account grant and revoke it through Platform in the background, without deleting API keys. * @returns state after removing the local account grant. diff --git a/packages/api/account-controller/tests/signout-impact.spec.ts b/packages/api/account-controller/tests/signout-impact.spec.ts new file mode 100644 index 0000000000..ff618dcae2 --- /dev/null +++ b/packages/api/account-controller/tests/signout-impact.spec.ts @@ -0,0 +1,23 @@ +/** Sign-out impact reads prepared providers rather than configured routes. */ +import { Context } from '@deepseek-ai/cordis' +import { expect, it } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { AccountController } from '../src/index.ts' + +it('reports only currently prepared account-token tasks', async () => { + const ctx = new Context() + const active: Array> = [] + // This query only consumes the registry list and each agent's prepared provider. + ctx.provide('agents') + ctx.set('agents', { list: () => active } as Context['agents']) + const controller = new AccountController(ctx) + try { + expect(controller.hasRunningAccountTasks()).toBe(false) + active.push({ activeProvider: undefined }, { activeProvider: 'deepseek-official' }) + expect(controller.hasRunningAccountTasks()).toBe(false) + active.push({ activeProvider: 'deepseek-account' }) + expect(controller.hasRunningAccountTasks()).toBe(true) + active.pop() + expect(controller.hasRunningAccountTasks()).toBe(false) + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/api/account-controller/tsconfig.json b/packages/api/account-controller/tsconfig.json index 82f75a2d8d..539e4ec237 100644 --- a/packages/api/account-controller/tsconfig.json +++ b/packages/api/account-controller/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../core/agent" } ] } diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index 46b3dff031..73d2f38451 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -49,6 +49,7 @@ async function harness(withJobs: boolean): Promise<{ options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx, send: () => {}, diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 4a92ad0991..33b246c74b 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -148,6 +148,7 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ options: createOptions.agentOptions ?? {}, session, inbox, + activeProvider: undefined, status: 'idle', ctx: ownerCtx, cancel: () => {}, diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index e1875f5ce2..3bff2ebfc5 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -52,6 +52,8 @@ function failureMessage( code: unknown, t: ChatViewSlotProps['t'], ): string { + if (code === 'ACCOUNT_SIGNED_OUT') return t('message.failure.accountSignedOut') + if (code === 'ACCOUNT_SIGN_IN_REQUIRED') return t('message.failure.accountSignInRequired') return code === 'AUTH' ? t('message.failure.auth') : message } @@ -130,7 +132,7 @@ function TurnErrorItem({ node, t }: {
- {t('message.turnError')} + {node.code === 'ACCOUNT_SIGNED_OUT' ? t('message.accountStopped') : t('message.turnError')} {failureMessage(node.message, node.code, t)}
{node.code !== undefined && {node.code}} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index 941f23ac43..445d073639 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -29,8 +29,13 @@ function lastStep(context: ConversationNodeContext): number { } function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { - if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined - const failure = match.event.data.reason.error + if (match.event.type !== 'turn/end') return undefined + const reason = match.event.data.reason + const failure = reason.kind === 'error' ? reason.error + : reason.kind === 'aborted' && reason.reason.kind === 'hook' + && reason.reason.reason === 'deepseek-account/signed-out' + ? { message: 'Stopped because you signed out of DeepSeek.', code: 'ACCOUNT_SIGNED_OUT' } : undefined + if (failure === undefined) return undefined const display = displayFailure(failure) return { seq: match.event.seq, @@ -58,7 +63,9 @@ export const turnErrorDefinition: ConversationNodeDefinition = { target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } - if (event.type === 'turn/end' && event.data.reason.kind === 'error') { + if (event.type === 'turn/end' && (event.data.reason.kind === 'error' + || (event.data.reason.kind === 'aborted' && event.data.reason.reason.kind === 'hook' + && event.data.reason.reason.reason === 'deepseek-account/signed-out'))) { return { id: String(event.data.turn), role: 'update' } } return null diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 344fef13d0..0107d47976 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -82,6 +82,9 @@ export const zh = { 'message.retry.delay': '重试延迟:', 'message.retry.failure': '失败原因:', 'message.failure.auth': 'API 密钥无效', + 'message.accountStopped': '任务已停止', + 'message.failure.accountSignedOut': '任务已因退出 DeepSeek 登录而停止。', + 'message.failure.accountSignInRequired': '请先登录 DeepSeek,并确认请求地址支持账号认证。', 'message.turnError': '本轮运行失败', 'message.maxTokens': '已达到输出 token 上限', 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', @@ -192,6 +195,9 @@ export const en = { 'message.retry.delay': 'Retry delay: ', 'message.retry.failure': 'Failure reason: ', 'message.failure.auth': 'API key is invalid', + 'message.accountStopped': 'Task stopped', + 'message.failure.accountSignedOut': 'Stopped because you signed out of DeepSeek.', + 'message.failure.accountSignInRequired': 'Sign in to DeepSeek and ensure the request destination supports account authentication.', 'message.turnError': 'This turn failed', 'message.maxTokens': 'Output token limit reached', 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index f116edab09..f107b8f678 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -2520,3 +2520,13 @@ describe('built-in conversation node Definitions', () => { }) }) }) + + +it('retains a sign-out cancellation notice when reopening a partial turn', () => { + const value = assembler([ + at(7, 'turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'hook', reason: 'deepseek-account/signed-out' } } }), + ], true) + expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({ + code: 'ACCOUNT_SIGNED_OUT', message: 'Stopped because you signed out of DeepSeek.', + }) +}) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 671cf8ecc9..4ca0dee8fc 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/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/client/ui-model-selection/README.md -README.md: 20d55111a298076a7f41db3a7bcd859f2c8433a6 -README.zh.md: d60c811e7fdd57936f11963916a835064e1fff96 +README.md: 96bf249b8976ad56b5abb70975635fa452c700f8 +README.zh.md: 71eb0de85bd87e5b2b92bf33d19f925e649f92a7 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 20d55111a2..96bf249b89 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -25,6 +25,8 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses ## Use this package +DeepSeek account and API-key routes appear as separate provider groups, each exposing the same configured model catalog. + Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`. ### Model and effort diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index d60c811e7f..71eb0de85b 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -25,6 +25,8 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 ## 使用本包 +DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示相同的已配置模型目录。 + 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。 ### 模型与推理强度 diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index d2e25be7c0..201ac8c732 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -48,6 +48,8 @@ function rowId(providerId: string, modelId: string): string { } const BUILTIN_DESCRIPTION_KEYS: Readonly> = { + 'deepseek-account/deepseek-v4-flash': 'option.deepseekV4Flash.description', + 'deepseek-account/deepseek-v4-pro': 'option.deepseekV4Pro.description', 'deepseek-official/deepseek-v4-flash': 'option.deepseekV4Flash.description', 'deepseek-official/deepseek-v4-pro': 'option.deepseekV4Pro.description', } diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index fb41dc5813..3e8fa7838f 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/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/client/ui-settings-account/README.md -README.md: 99053a5ffb5e85bb9754a1d2b00dffbadbaf51da -README.zh.md: aa89bce650374e5f2de15277cd53c0eb9cf8bc38 +README.md: e9df6c0f3dd20d47c688d3ca23b6cff88ab72e99 +README.zh.md: 775d518b1c0e75f7fbd191bc21e57ac24c7f8179 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 99053a5ffb..e9df6c0f3d 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -34,6 +34,8 @@ The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, a The account card’s More account information link opens `https://platform.deepseek.com` in the system browser on Desktop or a new tab on Web. +Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed queries keep the menu available; failed sign-out keeps the dialog available for retry. + ## Understand the implementation diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index aa89bce650..775d518b1c 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -34,6 +34,8 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 账号卡片的“更多账号信息”链接在 Desktop 中通过系统浏览器打开 `https://platform.deepseek.com`,在 Web 中打开新标签页。 +退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。查询失败保留菜单;退登失败保留弹窗以便重试。 + ## 理解实现 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index a6e80fb7ac..6122728558 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AccountSectionInjected } from './AccountSection.tsx' +import { SignOutDialog } from './SignOutDialog.tsx' import { SignInDialog } from './SignInDialog.tsx' import { LogoutIcon } from './LogoutIcon.tsx' import { AccountAvatar } from './AccountAvatar.tsx' @@ -16,7 +17,7 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<' * @returns account menu launcher. */ export function AccountMenu({ - wide, openSettings, openOnboarding, useAccount, signOut, contactUs, showLogin, start, cancel, t, + wide, openSettings, openOnboarding, useAccount, signOut, hasRunningAccountTasks, contactUs, showLogin, start, cancel, t, }: AccountMenuProps) { const account = useAccount(state => state) const signedIn = account.view?.status === 'credential-stored' @@ -26,10 +27,11 @@ export function AccountMenu({ const [open, setOpen] = useState(false) const [busy, setBusy] = useState(false) const [failed, setFailed] = useState(false) - const logout = async () => { + const [signOutImpact, setSignOutImpact] = useState() + const requestSignOut = async () => { setBusy(true) setFailed(false) - try { await signOut(); setOpen(false) } + try { setSignOutImpact(await hasRunningAccountTasks()); setOpen(false) } catch { setFailed(true) } finally { setBusy(false) } } @@ -51,10 +53,12 @@ export function AccountMenu({ if (id === 'settings') { setOpen(false); openSettings() } else if (id === 'contact') { setOpen(false); contactUs() } else if (id === 'signin') { setOpen(false); void start().catch(() => { setFailed(true) }) } - else if (id === 'signout') void logout() + else if (id === 'signout') void requestSignOut() }} /> {account.loginVisible && !account.onboarding && { showLogin(false) }} useApiKey={() => { showLogin(false); openOnboarding('deepseek-official') }} />} + {signedIn && signOutImpact !== undefined && { setSignOutImpact(undefined) }} t={t} />} {failed && {t('failed')}}
} diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index adc2f1f09c..e679c077f7 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -44,6 +44,8 @@ export interface AccountSectionInjected { start: () => Promise /** @param id - attempt to cancel. @returns after cancellation or an already-admitted commit. */ cancel: (id: SignInAttemptId) => Promise + /** @returns whether a running task currently uses the account token. */ + hasRunningAccountTasks: () => Promise /** @returns after local account credentials are removed. */ signOut: () => Promise } diff --git a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx new file mode 100644 index 0000000000..c0679a0b2d --- /dev/null +++ b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx @@ -0,0 +1,41 @@ +/** Account sign-out confirmation with the current task impact. */ +import { useState } from 'react' +import { Button, IconCloseOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import type { AccountKey } from './locales.ts' +import css from './SignInDialog.module.css' + +/** @param props - task impact at opening, localized copy, and account actions. @returns sign-out confirmation. */ +export function SignOutDialog({ running, signOut, close, t }: { + running: boolean + signOut: () => Promise + close: () => void + t: (key: AccountKey) => string +}) { + const [busy, setBusy] = useState(false) + const [failed, setFailed] = useState(false) + const dismiss = () => { if (!busy) close() } + const confirm = async () => { + setBusy(true) + setFailed(false) + try { await signOut(); close() } + catch { setFailed(true) } + finally { setBusy(false) } + } + return +
+
+

{t('signOut')}

+ +
+

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

+ {failed &&

{t('failed')}

} +
+
+ + +
+
+} diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index a6a8b79272..a5b1c715ab 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -120,6 +120,11 @@ export function apply(ctx: Context): void { } }, async cancel(id) { const result = await ctx.remote.account.cancelSignIn(id); if (!result.ok) throw new Error('account cancel failed') }, + async hasRunningAccountTasks() { + const result = await ctx.remote.account.hasRunningAccountTasks() + if (!result.ok) throw new Error('account task query failed') + return result.value + }, async signOut() { const result = await ctx.remote.account.signOut(); if (!result.ok) throw new Error('account sign-out failed') }, } ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index e332296af7..b93c8e61c0 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -9,7 +9,9 @@ export const en = { loading: 'Loading…', backToHarness: 'Back to DeepSeek Harness', settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu', nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in', - signIn: 'Sign in', signOut: 'Sign out', cancel: 'Cancel', open: 'Open browser', + signIn: 'Sign in', signOut: 'Sign out', + signOutDescription: 'Signing out will not delete any data. You can sign in to this account again.', + signOutRunningDescription: 'Tasks are currently running. Signing out will interrupt them. Sign out now?', cancel: 'Cancel', open: 'Open browser', initializing: 'Starting sign in…', waiting: 'Continue in your browser', completing: 'Completing sign in…', expired: 'Sign in expired. Try again.', failed: 'Could not complete the operation. Try again.', @@ -33,7 +35,9 @@ export const zh: Record = { loading: '加载中…', backToHarness: '返回 DeepSeek Harness', settings: '设置', contactUs: '联系我们', menu: '账号菜单', nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录', - signIn: '登录', signOut: '退出登录', cancel: '取消', open: '打开浏览器', + signIn: '登录', signOut: '退出登录', + signOutDescription: '退出登录不会丢失任何数据,你仍可以登录此账号。', + signOutRunningDescription: '当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?', cancel: '取消', open: '打开浏览器', initializing: '正在发起登录…', waiting: '请在浏览器中继续', completing: '正在完成登录…', expired: '登录已过期,请重试。', failed: '操作未完成,请重试。', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 9dfa3dd269..3757579a79 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -17,6 +17,7 @@ function mount(state: Omit, copy: typeof en | typeof zh = getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }), subscribe: () => () => {}, } }, + hasRunningAccountTasks: vi.fn(async () => false), contactUs: vi.fn(), showLogin: vi.fn(), setOnboarding: vi.fn(), refresh: vi.fn(() => Promise.resolve()), start: vi.fn(() => Promise.resolve()), cancel: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()), @@ -50,13 +51,14 @@ it('starts sign-in and disables cancellation during persistence', async () => { expect(screen.queryByRole('button', { name: en.signIn })).toBeNull() }) -it.each([en, zh])('opens settings and signs out from the sidebar account menu', async (copy) => { +it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { const signOut = vi.fn(() => Promise.resolve()) const openSettings = vi.fn() const operations = mount({ status: 'credential-stored', attempt: null }, copy) cleanup() const { AccountMenu } = await import('../src/client/AccountMenu.tsx') render( running} useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings} t={key => key in copy ? copy[key as AccountKey] : key} />) fireEvent.click(screen.getByRole('button', { name: copy.menu })) @@ -70,6 +72,9 @@ it.each([en, zh])('opens settings and signs out from the sidebar account menu', expect(screen.queryByRole('menu')).toBeNull() fireEvent.click(screen.getByRole('button', { name: copy.menu })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: copy.signOut })) }) + expect(signOut).not.toHaveBeenCalled() + expect(screen.getByText(running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(signOut).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() }) diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt new file mode 100644 index 0000000000..9e7ca254e0 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt @@ -0,0 +1 @@ +Sign outSigning out will not delete any data. You can sign in to this account again.CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt new file mode 100644 index 0000000000..9ad80dfc63 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt @@ -0,0 +1 @@ +退出登录退出登录不会丢失任何数据,你仍可以登录此账号。取消退出登录 diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt new file mode 100644 index 0000000000..005841b323 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt @@ -0,0 +1 @@ +Sign outTasks are currently running. Signing out will interrupt them. Sign out now?CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt new file mode 100644 index 0000000000..b3effa3b49 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt @@ -0,0 +1 @@ +退出登录当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?取消退出登录 diff --git a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx new file mode 100644 index 0000000000..9ee95e897f --- /dev/null +++ b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +/** Sign-out impact copy and explicit confirmation. */ +import { cleanup, fireEvent, render, screen, act } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { SignOutDialog } from '../src/client/SignOutDialog.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) +function mount(running: boolean, copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { + const close = vi.fn() + render( copy[key]} />) + return { signOut, close } +} +it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))( + 'confirms sign-out with localized task impact: $running', async ({ copy, running }) => { + const props = mount(running, copy) + await expect(`${screen.getByRole('dialog').textContent}\n`) + .toMatchFileSnapshot(`./expected/sign-out-${running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) + expect(props.signOut).not.toHaveBeenCalled() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) + expect(props.signOut).toHaveBeenCalledOnce() + expect(props.close).toHaveBeenCalledOnce() + }, +) +it.each(['cancel', 'close', 'escape'] as const)('dismisses with %s without signing out', (action) => { + const props = mount(true) + if (action === 'escape') fireEvent.keyDown(document, { key: 'Escape' }) + else fireEvent.click(screen.getByRole('button', { name: en[action] })) + expect(props.close).toHaveBeenCalledOnce() + expect(props.signOut).not.toHaveBeenCalled() +}) +it('keeps failures open for retry and blocks dismissal during sign-out', async () => { + const pending = Promise.withResolvers() + const signOut = vi.fn(() => pending.promise).mockRejectedValueOnce(new Error('offline')) + const props = mount(true, en, signOut) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signOut })) }) + expect(screen.getByRole('alert').textContent).toBe(en.failed) + expect(props.close).not.toHaveBeenCalled() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signOut })) }) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(props.close).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: en.signOut }).hasAttribute('disabled')).toBe(true) + await act(async () => { pending.resolve(undefined) }) + expect(props.close).toHaveBeenCalledOnce() +}) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 454baf309b..5d0264c3ce 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -51,6 +51,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 28d7b1027d..09a7b5bbcc 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -100,6 +100,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 8a503e803f..25942ade6f 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: 7c3073aefb90fa2ec74d521d6c9d97aff5f74ace -README.zh.md: 44621a0b02eadfc574f6f6b924f64cfeb4141cd3 +README.md: e58f05e619fc5a39d26edd71ed92c79fe04dcc81 +README.zh.md: 490a4a1849daee182357f6eaadc44a5339b060ca diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 7c3073aefb..e58f05e619 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -25,6 +25,8 @@ English | [中文](README.zh.md) ## Use this package +The running phase retains the final prepared provider through tool execution and retry preparation, resets it before publishing `turn/end`, and exposes it through `Agent.activeProvider`. Plugins can cancel matching live agents without consulting mutable settings or historical request headers. + Mount `dsh-agent-loop` in any composition that should run agents. It supplies the driver behind `ctx.agents` and starts any agents you declare in its config; both [`dsh-base`](../../bundle/base/README.md) and [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.md) mount it as an explicit row. ### Configure declarative agents diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 44621a0b02..490a4a1849 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +运行阶段在工具执行及重试准备期间保留最终准备完成的提供方,在发布 `turn/end` 前重置,并通过 `Agent.activeProvider` 暴露。插件可以取消匹配的运行中代理,无须读取可变设置或历史请求头。 + 在任何应运行 agent 的组合中挂载 `dsh-agent-loop`。它提供 `ctx.agents` 背后的驱动器,并启动你在配置中声明的 agent;[`dsh-base`](../../bundle/base/README.zh.md) 与 [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.zh.md) 都将它作为显式配置行挂载。 ### 配置声明式 agent diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 4ea7593551..f44e1aa024 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -46,7 +46,7 @@ type Phase = lastTurn: number wakeRequested: boolean } - | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean } + | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean; activeProvider?: string } type StepEndReason = Extract @@ -115,6 +115,10 @@ export class ReactLoopAgent implements Agent { return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running' } + get activeProvider(): string | undefined { + return this.phase.kind === 'running' ? this.phase.activeProvider : undefined + } + /** Commit a phase and publish its externally visible status transition. */ private setPhase(next: Phase): void { const previousStatus = this.status @@ -336,6 +340,7 @@ export class ReactLoopAgent implements Agent { this.throwError(error) } finally { try { + delete phase.activeProvider // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending this.session.append('turn/end', { turn, reason: turnEnds! }) } catch (error: unknown) { @@ -547,6 +552,7 @@ export class ReactLoopAgent implements Agent { config = proposedConfig } signal.throwIfAborted() + if (this.phase.kind === 'running') this.phase.activeProvider = config.provider return { config, ...preparedCall === undefined ? {} : { preparedCall } } } diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 739d18b990..f4a9eeb387 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -26,6 +26,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx, send: () => {}, diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 59f4628536..26ee824e52 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/core/agent/README.md -README.md: 8ba3dc730790acb0f792173e9ee01d2dc954957e -README.zh.md: 0718ae5f3f73b1fd0bb5b600de3a08c50e67421e +README.md: a07145131186fddb30de62536f3001a36b32dc04 +README.zh.md: d21c010e418c997807b3eec36a585a8b84bea31d diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8ba3dc7307..a071451311 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -25,6 +25,8 @@ Use `dsh-agent` to create or resume live agents, send follow-up or steering inpu ## Use this package +The read-only `Agent.activeProvider` identifies the current turn’s latest prepared request. It is absent before the first request is prepared and between turns; tools and retries retain the last provider until another request finishes preparation. Configuration changes alone do not reclassify in-flight work. + Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `Agent` handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is `dsh-agent-loop`, so the smallest useful composition loads both. ### Create or resume an agent diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 0718ae5f3f..d21c010e41 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +只读 `Agent.activeProvider` 标识当前轮次最近一次完成准备的请求提供方。首请求准备完成前及轮次之间没有该值;工具执行和重试期间保留上一提供方,直到另一请求完成准备。仅改变配置不会改变在途任务的归属。 + 在存在实时 agent 的任何地方挂载 `dsh-agent`:它提供 `ctx.agents` 以及插件、UI、钩子和编排器所面向编程的 `Agent` 句柄。在没有驱动器注册工厂之前,该服务保持惰性——随附驱动器是 `dsh-agent-loop`,因此最小的可用组合需要同时加载两者。 ### 创建或恢复 agent diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 2118507b8c..bb87e7986e 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -170,6 +170,8 @@ declare module './types.ts' { readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus + /** Latest prepared provider in this turn; retained through tools and retries, absent before preparation and between turns. */ + readonly activeProvider: string | undefined /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index c31acf5c66..99f20f2419 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -24,6 +24,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { inbox: { nextTurn: [], nextStep: [], } as never, + activeProvider: undefined, status: 'idle', ctx, send: () => {}, diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 7315a4b6c1..b896927357 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/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/credentials/deepseek-account-platform/README.md -README.md: 248f679dd7b27af35eafa59579df7538db213b23 -README.zh.md: 36c3f861d60f2c02cca76feabed85862630ead98 +README.md: 31172b1749428decebed41fac99e3fc34a70cf6d +README.zh.md: bdff2428021b7452865cb10d6774fff618c3b111 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 248f679dd7..31172b1749 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -28,6 +28,8 @@ Sign in through the system browser and keep the account credential in the existi The profile projection maps `id_profile.picture` to `avatarUrl`, using null when no picture is configured. +Token resolution refuses requests while local sign-out is in progress. Successful local removal publishes `deepseek-account/signed-out` before returning the signed-out snapshot; remote revocation remains independent. + getProfile / getBalance sends the stored grant in the x-dsh-auth-token header to GET /auth-api/v0/users/current and GET /api/v0/users/get_user_summary on platformOrigin. The grant issuer must match that origin. Host projects only the account UID, profile name, avatar URL, phone or email exactly as masked by Platform, and normal_wallets / bonus_wallets currency/balance strings; response tokens and other fields are discarded. Bonus wallets remain separate from recharge balances. Credential changes and disposal invalidate in-flight queries. Configure platformOrigin, allowLoopbackHttp, requestTimeoutMs, and attemptTimeoutMs in the plugin row. HTTP is accepted only for explicitly enabled loopback development. The provider registers /oauth/callback on the existing Host webServer before auth_init, validates state, exchanges an S256 PKCE code once, and commits a grant before redirecting to auth_exchange.biz_data.authorized_url. Browser destinations default to the configured platform origin and always require the fixed /dsh/authorize or /dsh/authorized path. The completion URL sets `login_source` to the initiating client (`web` or `desktop`) and preserves other Platform-supplied query parameters. Device identity is a separate random UUID record shared by processes using the same credential store; device_model reports OS and architecture. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 36c3f861d6..bdff242802 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -26,6 +26,8 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 ## 使用此包 +本地退出登录期间拒绝解析 token。本地授权成功删除后,先发布 `deepseek-account/signed-out`,再返回已退出的状态快照;远程撤销独立执行。 + 账号资料投影将 `id_profile.picture` 映射为 `avatarUrl`,没有配置头像时返回 null。 getProfile / getBalance 将保存的授权 token 通过 x-dsh-auth-token 请求头,向 platformOrigin 上的 GET /auth-api/v0/users/current 和 GET /api/v0/users/get_user_summary 发起请求。授权签发来源必须与该来源一致。Host 只投影账号 UID、资料名称、头像 URL、由 Platform 脱敏的手机号或邮箱(原样保留),以及 normal_wallets / bonus_wallets 的币种和余额字符串,丢弃响应 token 与其他字段。赠送钱包不计入充值余额。凭证变化和销毁会使进行中的查询失效。 diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index bea62a3558..835b79d7da 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -222,6 +222,7 @@ export class PlatformAccount extends DeepSeekAccount { } override async resolveToken(url: string): Promise { + if (this.closed || this.removing !== undefined) return undefined const destination = new URL(url) if (destination.origin !== this.inferenceOrigin || destination.username || destination.password) return undefined const record = await this.ctx.credentials.readRecord(KEY) @@ -311,6 +312,7 @@ export class PlatformAccount extends DeepSeekAccount { this.revoke(parsed.data.token) } this.attempt = undefined + this.ctx.emit('deepseek-account/signed-out') this.changed() return this.getState() })().finally(() => { this.removing = undefined }) diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index 407a56f89b..d04dc498ce 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -329,6 +329,18 @@ it('retains profile data when balance fails instead of reporting a zero balance' expect(await readDetails(f.account)).toMatchObject({ profile: { status: 'ready' }, balance: { status: 'failed' } }) }) +it('publishes sign-out only after local grant removal and refuses token resolution while removing', async () => { + const f = await fixture() + await storeAccount(f) + const observed: string[] = [] + f.ctx.on('deepseek-account/signed-out', () => { observed.push('signed-out') }) + const pending = f.account.signOut() + expect(await f.account.resolveToken('https://api.deepseek.com')).toBeUndefined() + await pending + expect(observed).toEqual(['signed-out']) + expect((await f.account.getState()).status).toBe('signed-out') +}) + it('discards account details when sign-out races the Platform response', async () => { const f = await fixture() await storeAccount(f) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 7d8ddfe1f3..4841809a90 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/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/credentials/deepseek-account/README.md -README.md: 72b42ee996b809992e48ea4c124f0793d756526a -README.zh.md: b30813e24adc2afc9e8245b839432dd195093c63 +README.md: 52bc8e15ee72282b79c0b8823b56476650cdf728 +README.zh.md: 427b4a22aaf1ed503d41e90d0db600d220ef931e diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 72b42ee996..52bc8e15ee 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -26,6 +26,8 @@ Account consumers read stored login state, start or cancel a browser login, and `AccountProfile.avatarUrl` is an optional profile image URL; null or absence means no avatar. +Successful local sign-out emits `deepseek-account/signed-out`. Consumers own any cancellation policy; the account service does not track agents or model providers. + The service defines account operations and reconnectable state snapshots. The platform provider owns the protocol and stored grant. Credentials are Host-only; the API controller exports state and commands without resolveToken. diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index b30813e24a..427b4a22aa 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -24,6 +24,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token ## 使用此包 +本地成功退出登录后发出 `deepseek-account/signed-out`。消费方负责取消策略;账号服务不跟踪 agent 或模型提供方。 + `AccountProfile.avatarUrl` 是可选的账号头像 URL;为空或缺失时表示没有头像。 该服务定义账号操作和可重连的状态快照。平台提供者负责协议和授权记录。凭证仅限 Host;API 控制器只导出状态与操作,不导出 resolveToken。 diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index 465bbedf03..804dc512f6 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -4,6 +4,12 @@ import type { AccountDetails, AccountView, SignInAttemptId } from './types.ts' export type { AccountDetails, AccountProfile, AccountWallet, AccountLinks, AccountView, SignInAttemptId, SignInAttemptView, SignInErrorCode } from './types.ts' declare module '@deepseek-ai/cordis' { + interface Events { + /** Local grant removal has completed. + * @mode emit + */ + 'deepseek-account/signed-out'(): void + } interface Context { deepseekAccount: DeepSeekAccount } @@ -53,7 +59,7 @@ export abstract class DeepSeekAccount extends Service { */ abstract cancelSignIn(id: SignInAttemptId): Promise /** - * Remove the local grant while retaining API keys and tasks; the provider revokes it in the background. + * Remove the local grant while retaining API keys; the provider revokes it in the background. * @returns the signed-out state after local removal; remote failures never restore the grant. */ abstract signOut(): Promise diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index 6a603163bd..947d5acedc 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -35,6 +35,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', get ctx() { return scope.ctx }, send: () => {}, diff --git a/packages/experimental/browser-use-runtime/tests/resources.spec.ts b/packages/experimental/browser-use-runtime/tests/resources.spec.ts index f838191d44..104489f420 100644 --- a/packages/experimental/browser-use-runtime/tests/resources.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/resources.spec.ts @@ -16,7 +16,7 @@ async function fixture() { const fiber = ctx.plugin(() => {}) const session = Session.create(SessionId(id)) const agent: Agent = { - id: session.id, session, ctx: fiber.ctx, options: {}, status: 'idle', + id: session.id, session, ctx: fiber.ctx, options: {}, activeProvider: undefined, status: 'idle', inbox: unsupportedInbox(), send() {}, followup() {}, inject() {}, cancel() {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), runMaintenance: task => task(new AbortController().signal), diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a56e94240d..9e8c680d18 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -895,7 +895,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract signOut(): Promise', - description: 'Remove the local grant while retaining API keys and tasks; the provider revokes it in the background.', + description: 'Remove the local grant while retaining API keys; the provider revokes it in the background.', parameters: [], returns: 'the signed-out state after local removal; remote failures never restore the grant.', }, @@ -3744,6 +3744,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation\'s outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.', parameters: [{ name: 'ref', description: 'the reference whose stored value changed.' }], }, + { + name: 'deepseek-account/signed-out', + mode: 'emit', + signature: '\'deepseek-account/signed-out\'(): void', + summary: 'Local grant removal has completed.', + description: 'Local grant removal has completed.', + parameters: [], + }, { name: 'domain/changed', mode: 'emit', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 4e5401c09e..7db9617a08 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -38,6 +38,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } session, inbox: unsupportedInbox(), ctx: new Context(), + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 273657e579..ee442e64f0 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -37,6 +37,7 @@ async function agent(ctx: Context): Promise { session, inbox: unsupportedInbox(), ctx: scope.ctx, + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 1f893f37fe..6abb17030f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -38,6 +38,7 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1e4e65c7a1..3ad29e5b3b 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -30,6 +30,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } session, inbox, ctx: new Context(), + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 06e31d7196..68f71918ab 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -59,6 +59,7 @@ function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent session, inbox, ctx: agentCtx, + activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 227da38ad8..9bc7ae6693 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -44,6 +44,7 @@ async function liveAgent(ctx: Context, session: Session): Promise { session, inbox: unsupportedInbox(), ctx, + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index ecc7822bce..9e606e4bb1 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -58,6 +58,7 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St options: {}, session, inbox, + activeProvider: undefined, get status() { return status }, ctx: agentCtx, send: () => {}, diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index dafd91844f..1e0c300f8f 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -36,6 +36,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle' as const, ctx: agentCtx, send: () => {}, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 9445acaf60..11c68612d8 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: 5a3a3a4ff1cf3c30e8c44f079dc90069654efeac -README.zh.md: d58364c0ac21bd67d21761fc732bf9d34dfac802 +README.md: abd29fbdc49a100fa3e5a92ad4db05fc487ef456 +README.zh.md: d955dc40190a2b00d9034859c99c004ba06a4fa3 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5a3a3a4ff1..abd29fbdc4 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -234,6 +234,6 @@ This Dev Note is non-authoritative working context: undecided directions and not **Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. -When the account provider is mounted, its stored token takes priority only for https://api.deepseek.com. Other origins and signed-out accounts retain the configured API-key reference. Chat and Files requests reject redirects. Signing out removes only the account grant and preserves API keys. +`deepseek-official` uses only its configured API-key reference; `deepseek-account` uses only the stored DSH grant for the account provider’s allowed inference origin. Both routes share protocol, model, and file settings. Missing or ineligible account credentials reject the request with a sign-in prompt; neither route falls back to the other. Chat and Files requests reject redirects. Successful sign-out cancels live agents whose current prepared provider is `deepseek-account`, including tool execution, while preserving pending inbox messages and independent API-key tasks. DSH account tokens use x-dsh-auth-token without a Bearer prefix for both inference protocols and all Files operations. API keys retain each protocol’s existing authentication header. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index d58364c0ac..d955dc4019 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -234,6 +234,6 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 **运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 -挂载账号提供者时,其已保存 token 仅对 https://api.deepseek.com 优先生效。其他来源和已退出的账号继续使用配置的 API Key 引用。聊天和文件请求拒绝重定向。退出登录只删除账号授权记录,保留 API Key。 +`deepseek-official` 仅使用配置的 API Key 引用;`deepseek-account` 仅在账号提供方允许的推理来源使用已保存的 DSH 授权。两条路由共享协议、模型和文件设置。账号凭证缺失或不适用于目标时拒绝请求并提示登录;两条路由均不回退到另一凭证。Chat 和 Files 请求拒绝重定向。退登成功会取消当前已准备提供方为 `deepseek-account` 的运行代理,包括工具执行阶段,同时保留待处理消息和独立的 API Key 任务。 DSH 账号 token 在两种推理协议和所有 Files 操作中使用 x-dsh-auth-token,不加 Bearer 前缀。API Key 保持各协议现有的认证请求头。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 9f9d194442..5b031e3e5d 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -39,7 +39,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-deepseek-account": "workspace:^" + "@deepseek-ai/dsh-deepseek-account": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^" }, "dependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -79,6 +80,9 @@ "peerDependenciesMeta": { "@deepseek-ai/dsh-deepseek-account": { "optional": true + }, + "@deepseek-ai/dsh-agent": { + "optional": true } } } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 722f69bc3f..c25125987e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -23,7 +23,7 @@ export class DeepSeekAdapter extends LlmAdapter { return new DeepSeekMessagesAdapter({ connection: () => connection, apiKey: this.dependencies.resolveApiKey, - ...this.dependencies.resolveAccountToken === undefined ? {} : { accountToken: this.dependencies.resolveAccountToken }, + accountCredential: this.dependencies.accountCredential === true, userId: this.dependencies.resolveUserId, attachments: () => this.dependencies.resolveAttachments?.(), imageAccess: (ref) => { @@ -41,7 +41,7 @@ export class DeepSeekAdapter extends LlmAdapter { } } - override providerInfo(provider: string) { return this.implementation().providerInfo(provider) } + override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek (Account)' : 'DeepSeek (API Key)' } } override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } override listModels(provider: string) { return this.implementation().listModels(provider) } override resolveModel(provider: string, model: string, signal?: AbortSignal) { diff --git a/packages/llm/llm-deepseek/src/common/types.ts b/packages/llm/llm-deepseek/src/common/types.ts index 21fe8f37a7..07072e92e7 100644 --- a/packages/llm/llm-deepseek/src/common/types.ts +++ b/packages/llm/llm-deepseek/src/common/types.ts @@ -94,14 +94,14 @@ export interface DeepSeekAdapterOptions { /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** - * Resolve the bearer token for the connection facts of one request. The + * Resolve this adapter’s credential for the connection facts of one request. The * snapshot is passed in — never re-read — so the key can only ever come * from the same resolution as the endpoint it is sent to. Throws `LlmError` - * `MISSING_CREDENTIAL` when no key is available anywhere. + * `MISSING_CREDENTIAL` for a missing API key; account routes reject unavailable grants. */ resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise - /** Resolve a DSH account token only for an eligible official endpoint. */ - resolveAccountToken?: (connection: DeepSeekConnectionOptions) => Promise + /** Send the resolved credential as a bare DSH account token; never fall back to an API key. */ + accountCredential?: boolean /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ resolveUserId: () => AnonymousUserId /** Resolve the current durable attachment service; absence rejects image input. */ diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 6cf7922f6c..c740a9e449 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,5 +1,6 @@ /** Register DeepSeek with protocol selection and request-local settings and credentials. */ import type {} from '@deepseek-ai/dsh-deepseek-account' +import type {} from '@deepseek-ai/dsh-agent' import type { Context } from '@deepseek-ai/cordis' import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' @@ -107,13 +108,17 @@ export function apply(ctx: Context, config: Config): void { let userId: AnonymousUserId | undefined const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() - const adapter = new DeepSeekAdapter({ + const adapter = (accountCredential: boolean): DeepSeekAdapter => new DeepSeekAdapter({ + accountCredential, options, onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`) }, - resolveApiKey, - resolveAccountToken: connection => ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) ?? Promise.resolve(undefined), + resolveApiKey: accountCredential ? async (connection) => { + const token = await ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) + if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED') + return token + } : resolveApiKey, resolveUserId, resolveAttachments: () => ctx.get('attachments'), resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( @@ -128,11 +133,21 @@ export function apply(ctx: Context, config: Config): void { }, }) ctx.llm.registerConfigurableProviders([ - { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, + { provider: PROVIDER, displayName: 'DeepSeek (API Key)', settingsNs: NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. - const registration = ctx.llm.registerAdapter([PROVIDER], adapter) + const registration = ctx.llm.registerAdapter([PROVIDER], adapter(false)) + const accountRegistration = ctx.llm.registerAdapter(['deepseek-account'], adapter(true)) + ctx.inject(['agents'], (agentCtx) => { + agentCtx.on('deepseek-account/signed-out', () => { + for (const agent of agentCtx.agents.list()) { + if (agent.activeProvider === 'deepseek-account') { + agent.cancel({ kind: 'hook', reason: 'deepseek-account/signed-out' }, { keepInbox: true }) + } + } + }) + }) let registeredPolicy = options().retryPolicy const ensureRegistrationFacts = (): void => { const policy = options().retryPolicy @@ -143,6 +158,7 @@ export function apply(ctx: Context, config: Config): void { // publish an empty route set between the two, and an observer that reacted // to it would see this provider disappear and come back. registration.replace([PROVIDER]) + accountRegistration.replace(['deepseek-account']) registeredPolicy = policy } diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts index 6e32d4d703..78b7810dca 100644 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts +++ b/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts @@ -193,8 +193,7 @@ export class ChatCompletionsAdapter extends LlmAdapter { ) } } - const accountToken = await this.config.resolveAccountToken?.(connection) - const apiKey = accountToken ?? await this.config.resolveApiKey(connection) + const apiKey = await this.config.resolveApiKey(connection) const userId = this.config.resolveUserId() const consumer = new AbortController() const upstream = options.signal === undefined @@ -206,7 +205,7 @@ export class ChatCompletionsAdapter extends LlmAdapter { watchdog.signal, connection, apiKey, - accountToken !== undefined, + this.config.accountCredential === true, userId, attachments, () => { watchdog.pulse() }, diff --git a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts b/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts index f669be7ed3..ade2e75d2f 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts +++ b/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts @@ -24,7 +24,7 @@ export interface AdapterDependencies { /** Resolve the key named by that same generation. */ apiKey(connection: Connection): Promise /** DSH account token for an eligible official endpoint. */ - accountToken?(connection: Connection): Promise + accountCredential?: boolean /** Stable anonymous Harness identity. */ userId(): string /** Current attachment service; absence is valid for text requests. */ @@ -94,10 +94,9 @@ export class DeepSeekMessagesAdapter extends LlmAdapter { const { messages, versions } = await prepareImages( options.messages, connection, options.model, this.dependencies.attachments(), this.dependencies.imageAccess, signal, ) - const accountToken = await this.dependencies.accountToken?.(connection) - const key = accountToken ?? await this.dependencies.apiKey(connection) + const key = await this.dependencies.apiKey(connection) const files = new RequestFiles(this.dependencies.files(), { - baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, protocol: 'messages', + baseURL: connection.baseURL, apiKey: key, accountCredential: this.dependencies.accountCredential === true, protocol: 'messages', }, connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) let inline = false @@ -129,7 +128,7 @@ export class DeepSeekMessagesAdapter extends LlmAdapter { headers: { ...attributionHeaders(), 'content-type': 'application/json', 'accept': 'text/event-stream', - ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, + ...this.dependencies.accountCredential === true ? { 'x-dsh-auth-token': key } : { 'x-api-key': key }, 'anthropic-version': '2023-06-01', ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, 'x-deepseek-harness-user-id': this.dependencies.userId(), diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts new file mode 100644 index 0000000000..64b050fd02 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -0,0 +1,216 @@ +/** Account route cancellation uses the real loop, registry, and tool lifecycle. */ +import { afterEach, expect, it, vi } from 'vitest' +import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { createUserMessage, LlmAdapter, type GenerateOptions, type StreamChunk, ToolCallId } from '@deepseek-ai/dsh-llm' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import * as DeepSeek from '../src/index.ts' +import { assemble } from './assemble.ts' + +const contexts: Context[] = [] +afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +async function harness() { + const ctx = new Context() + contexts.push(ctx) + for (const plugin of [LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, AgentRegistry]) { + await ctx.plugin(plugin) + } + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(DeepSeek, { protocol: 'chat-completions' }) + return ctx +} + +function send(agent: Agent) { + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) +} + +class ScriptedAdapter extends LlmAdapter { + constructor(private run: (request: GenerateOptions) => AsyncIterable) { super() } + override stream(request: GenerateOptions) { return this.run(request) } +} + +it.each(['chat-completions', 'messages'] as const)('never falls back to an API key while signed out: %s', async (protocol) => { + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-api-key') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeek, { protocol }) + const fetch = vi.spyOn(globalThis, 'fetch') + const result = await assemble(ctx, { provider: 'deepseek-account', model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'ACCOUNT_SIGN_IN_REQUIRED' } }) + expect(fetch).not.toHaveBeenCalled() +}) + +it.each(['deepseek-account', 'deepseek-official'])('cancels only the active account route during tools: %s', async (provider) => { + const ctx = await harness() + const toolEntered = Promise.withResolvers() + const release = Promise.withResolvers() + const adapter = new ScriptedAdapter(async function* () { + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('fixture-call'), name: 'wait', arguments: '{}' } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + }) + ctx.on('llm/stream', request => adapter.stream(request)) + const agent = await ctx.agentLoop.create(SessionId(`tools-${provider}`), { provider, model: 'deepseek-v4-flash' }) + ctx.tools.register(defineContentToolFixture({ name: 'wait', description: '', parameters: {}, async execute() { + toolEntered.resolve(undefined) + await release.promise + return [{ type: 'text', text: 'done' }] + } })) + send(agent) + await toolEntered.promise + expect(agent.activeProvider).toBe(provider) + agent.inbox.splice('next-turn', Infinity, 0, [createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })]) + ctx.emit('deepseek-account/signed-out') + // Changing settings cannot reclassify the already running tool call. + expect(agent.activeProvider).toBe(provider) + expect(agent.inbox.nextTurn).toHaveLength(1) + if (provider === 'deepseek-official') agent.cancel({ kind: 'user' }, { keepInbox: true }) + release.resolve(undefined) + await agent.whenIdle() + expect(agent.activeProvider).toBeUndefined() + expect(agent.inbox.nextTurn).toHaveLength(1) + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'aborted', reason: provider === 'deepseek-account' + ? { kind: 'hook', reason: 'deepseek-account/signed-out' } : { kind: 'user' } } }) +}) + +it.each(['tool', 'retry'])('replaces the active provider only after the next request binds: %s', async (transition) => { + const ctx = await harness() + const preparing = Promise.withResolvers() + const proceed = Promise.withResolvers() + const streaming = Promise.withResolvers() + let requests = 0 + ctx.on('agent/request', async (_event, next) => { + const config = await next() + if (++requests === 1) return config + preparing.resolve(undefined) + await proceed.promise + return { ...config, provider: 'deepseek-official' } + }) + ctx.on('agent/request-error', async (_event, next) => { await next(); return { kind: 'retry' } }) + ctx.on('llm/stream', async function* (request) { + if (request.provider === 'deepseek-account') { + if (transition === 'retry') { + yield { type: 'finish', reason: { kind: 'error', failure: { code: 'TRANSPORT', message: 'retry fixture' } } } + return + } + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('switch-call'), name: 'continue', arguments: '{}' } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + const signal = request.signal! + streaming.resolve(signal) + await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) + signal.throwIfAborted() + }) + ctx.tools.register(defineContentToolFixture({ name: 'continue', description: '', parameters: {}, execute: async () => [{ type: 'text', text: 'done' }] })) + const agent = await ctx.agentLoop.create(SessionId('switch-provider'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + expect(agent.activeProvider).toBeUndefined() + send(agent) + await preparing.promise + expect(agent.activeProvider).toBe('deepseek-account') + proceed.resolve(undefined) + const signal = await streaming.promise + expect(agent.activeProvider).toBe('deepseek-official') + ctx.emit('deepseek-account/signed-out') + expect(signal.aborted).toBe(false) + agent.cancel({ kind: 'user' }) + await agent.whenIdle() + expect(agent.activeProvider).toBeUndefined() +}) + +it('does not infer a provider while the first request is still being prepared', async () => { + const ctx = await harness() + const preparing = Promise.withResolvers() + const proceed = Promise.withResolvers() + ctx.on('agent/request', async ({ signal }, next) => { + preparing.resolve(undefined) + await proceed.promise + expect(signal.aborted).toBe(false) + return next() + }) + const agent = await ctx.agentLoop.create(SessionId('first-preparation'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + send(agent) + await preparing.promise + expect(agent.activeProvider).toBeUndefined() + ctx.emit('deepseek-account/signed-out') + proceed.resolve(undefined) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'error', error: { code: 'ACCOUNT_SIGN_IN_REQUIRED' } } }) +}) + + +it.each(['chat-completions', 'messages'] as const)('never borrows an account token for a missing API key: %s', async (protocol) => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + const resolveToken = vi.fn(async () => 'fixture-account-token') + ctx.provide('deepseekAccount', { resolveToken } as unknown as DeepSeekAccount) + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeek, { protocol }) + const fetch = vi.spyOn(globalThis, 'fetch') + const result = await assemble(ctx, { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + expect(resolveToken).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + + +it('propagates account sign-out to the HTTP request signal', async () => { + const ctx = await harness() + ctx.provide('deepseekAccount', { resolveToken: async () => 'fixture-account-token' } as unknown as DeepSeekAccount) + const started = Promise.withResolvers() + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const signal = init!.signal! + expect(new Headers(init!.headers).get('x-dsh-auth-token')).toBe('fixture-account-token') + started.resolve(signal) + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('request aborted', { cause: signal.reason })) }, { once: true }) + }) + }) + const agent = await ctx.agentLoop.create(SessionId('http-abort'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + send(agent) + const signal = await started.promise + ctx.emit('deepseek-account/signed-out') + await agent.whenIdle() + expect(signal.aborted).toBe(true) + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'aborted', reason: { kind: 'hook', reason: 'deepseek-account/signed-out' } } }) +}) + + +it('cancels an account child without stopping its API-key parent', async () => { + const ctx = await harness() + const parentStarted = Promise.withResolvers() + const childStarted = Promise.withResolvers() + ctx.on('llm/stream', async function* (request) { + const signal = request.signal! + if (request.provider === 'deepseek-account') childStarted.resolve(signal) + else parentStarted.resolve(signal) + yield { type: 'block-start', index: 0, blockType: 'text' } + await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) + signal.throwIfAborted() + }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const childHandle = await ctx.agents.create({ sessionId: SessionId('child'), parentAgent: parent, + agentOptions: { provider: 'deepseek-account', model: 'deepseek-v4-flash' } }) + send(parent) + send(childHandle.agent) + const [parentSignal, childSignal] = await Promise.all([parentStarted.promise, childStarted.promise]) + ctx.emit('deepseek-account/signed-out') + await childHandle.agent.whenIdle() + expect(childSignal.aborted).toBe(true) + expect(parentSignal.aborted).toBe(false) + parent.cancel({ kind: 'user' }) + await parent.whenIdle() +}) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 77b0beb741..2c95e5db00 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1697,10 +1697,10 @@ describe('plugin registration and config', () => { protocol: 'chat-completions', baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', - displayName: 'DeepSeek', + displayName: 'DeepSeek (API Key)', settingsNs: 'llm-deepseek', settingsPath: [], }]) @@ -1733,7 +1733,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -2186,7 +2186,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) }) it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { @@ -2196,7 +2196,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) // First-boot onboarding: the route registers so models stay discoverable; // only the request itself needs a key. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) @@ -2277,7 +2277,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { @@ -2360,11 +2360,11 @@ describe('plugin registration and config', () => { it.each([ - ['chat-completions', 'https://api.deepseek.com', 'account-token'], - ['messages', 'https://api.deepseek.com', 'account-token'], - ['chat-completions', 'https://custom.example.test', 'ambient-key'], - ['messages', 'https://custom.example.test', 'ambient-key'], -] as const)('selects account or API-key credentials from the actual endpoint %s', async (protocol, baseURL, expected) => { + ['chat-completions', 'deepseek-account', 'account-token'], + ['messages', 'deepseek-account', 'account-token'], + ['chat-completions', 'deepseek-official', 'ambient-key'], + ['messages', 'deepseek-official', 'ambient-key'], +] as const)('keeps provider credentials independent for %s %s', async (protocol, provider, expected) => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const ctx = new Context() // This consumer uses only resolveToken; the real provider owns origin validation in its own suite. @@ -2372,7 +2372,7 @@ it.each([ resolveToken: (url: string) => Promise.resolve(url === 'https://api.deepseek.com' ? 'account-token' : undefined), } as DeepSeekAccount) await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { baseURL, protocol }) + await ctx.plugin(LlmDeepSeek, { baseURL: 'https://api.deepseek.com', protocol }) const request = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { const headers = new Headers(init?.headers) expect(headers.get('authorization')).toBe(expected === 'account-token' || protocol === 'messages' ? null : `Bearer ${expected}`) @@ -2382,7 +2382,7 @@ it.each([ return Promise.resolve(new Response('fixture rejection', { status: 400 })) }) try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { provider, model: 'deepseek-v4-flash', messages: [] }) expect(request).toHaveBeenCalledOnce() - } finally { request.mockRestore() } + } finally { request.mockRestore(); await ctx.fiber.dispose() } }) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index b1bae1c4bc..e366466ab9 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -58,6 +58,9 @@ }, { "path": "../../credentials/deepseek-account" + }, + { + "path": "../../core/agent" } ] } diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index d86859ac50..22eba5d3be 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -63,6 +63,7 @@ async function harness(): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index a34281c1b5..c7775725ba 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -30,6 +30,7 @@ function stubAgent(ctx: Context, id: string): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index ea3688fefb..a520878b48 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -49,6 +49,7 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index a79d116556..5c114c1c81 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -43,6 +43,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index eacd51d586..22f9951599 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -56,6 +56,7 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index c260f818eb..b7cb58ccac 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -43,6 +43,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 7d9fb0d2f0..15528c1074 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -73,6 +73,7 @@ function agentForCwd(cwd: string): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, @@ -90,6 +91,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 16b7f9ffce..9904d11c8e 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -56,6 +56,7 @@ function agent(ctx: Context, cwd?: string): Agent { }) return { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx, send: () => {}, @@ -658,6 +659,7 @@ describe('terminal-bash plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, @@ -708,6 +710,7 @@ describe('terminal-bash plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 7ef28c6929..31a870becd 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -41,6 +41,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const session = Session.create(id) const agent: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 11da341945..d43654b997 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -28,6 +28,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scopeFiber.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index b983872934..8279699981 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -44,6 +44,7 @@ async function agent(ctx: Context): Promise { const session = Session.create(id) const value: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 934b4c6d60..e106a8c50a 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -20,6 +20,7 @@ async function fakeAgent(ctx: Context, rawId: string): Promise { const session = Session.create(id) const agent: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index a1e76b01b3..5ec74013fe 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -35,6 +35,7 @@ async function agent(ctx: Context): Promise { const session = Session.create(id) const value: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81dfd23e8a..7778620938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -948,6 +948,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-deepseek-account': specifier: workspace:^ version: link:../../credentials/deepseek-account diff --git a/python/sdk/tests/expected/account-provider-signout.json b/python/sdk/tests/expected/account-provider-signout.json new file mode 100644 index 0000000000..64969395aa --- /dev/null +++ b/python/sdk/tests/expected/account-provider-signout.json @@ -0,0 +1,11 @@ +{ + "final_response": "Before sign-out", + "finish_reason": "aborted", + "reason": { + "kind": "aborted", + "reason": { + "kind": "hook", + "reason": "deepseek-account/signed-out" + } + } +} diff --git a/python/sdk/tests/test_account_provider_snapshot.py b/python/sdk/tests/test_account_provider_snapshot.py new file mode 100644 index 0000000000..5d27eb5522 --- /dev/null +++ b/python/sdk/tests/test_account_provider_snapshot.py @@ -0,0 +1,35 @@ +"""Project the recorded account cancellation through the real Python SDK.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness + +ROOT = Path(__file__).resolve().parents[3] +CLI = ROOT / 'apps' / 'cli' / 'lib' / 'bin.js' + + +@pytest.mark.skipif(not CLI.exists(), reason='requires the built dsh profile runtime') +def test_account_provider_signout_snapshot(tmp_path: Path) -> None: + with DeepSeekHarness( + dsh_bin=str(CLI), + dsh_home=str(tmp_path / 'home'), + cwd=str(tmp_path), + profile='sdk', + patches=(str(ROOT / 'snapshots' / 'sdk' / 'account-provider-signout' / 'cordis.yml'),), + provider='deepseek-account', + model='deepseek-v4-flash', + env={'DSH_TELEMETRY_DISABLED': '1', 'DSH_SNAPSHOT': 'replay'}, + ) as harness: + result = harness.run('Reply with exactly: SDK snapshot') + ending = next(event for event in reversed(result.events) if event['type'] == 'turn/end') + actual = { + 'final_response': result.final_response, + 'finish_reason': result.finish_reason, + 'reason': ending['data']['reason'], + } + expected = Path(__file__).with_name('expected') / 'account-provider-signout.json' + assert actual == json.loads(expected.read_text()) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e2adb7f7e0..838cf6d998 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -216,6 +216,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'cordis': 'extensions.md', 'authorization': 'credentials.md', 'credentials': 'credentials.md', + 'deepseek-account': 'credentials.md', 'domain': 'storage.md', 'fs': 'filesystem.md', 'goal': 'goal.md', diff --git a/snapshots/sdk/account-provider-signout/account-signout.mjs b/snapshots/sdk/account-provider-signout/account-signout.mjs new file mode 100644 index 0000000000..c468f6022b --- /dev/null +++ b/snapshots/sdk/account-provider-signout/account-signout.mjs @@ -0,0 +1,11 @@ +/** Deterministic transport boundary; cancellation belongs to the shipped DeepSeek plugin. */ +export const inject = ['llm', 'agents'] +export function apply(ctx) { + ctx.on('llm/stream', async function* (request, next) { + if (request.provider !== 'deepseek-account') { yield* next(); return } + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'Before sign-out' } + ctx.emit('deepseek-account/signed-out') + request.signal.throwIfAborted() + }) +} diff --git a/snapshots/sdk/account-provider-signout/cordis.snapshot.yml b/snapshots/sdk/account-provider-signout/cordis.snapshot.yml new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/cordis.snapshot.yml @@ -0,0 +1 @@ +[] diff --git a/snapshots/sdk/account-provider-signout/cordis.yml b/snapshots/sdk/account-provider-signout/cordis.yml new file mode 100644 index 0000000000..dc92a814c0 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/cordis.yml @@ -0,0 +1,33 @@ +# TypeScript SDK snapshot-record patch over `dsh --profile sdk`. + +# The fixture corpus must not depend on the developer's personal skill roots. +# Ordinary product launches retain the sdk profile's default skill discovery. +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + config: + includeDefaultRoots: false + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + thinking: enabled + reasoningEffort: max + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + enableRunInBackground: false + maxDepth: 1 + +- insert: + - id: account-signout-fixture + name: './account-signout.mjs' diff --git a/snapshots/sdk/account-provider-signout/notifications.expected.jsonl b/snapshots/sdk/account-provider-signout/notifications.expected.jsonl new file mode 100644 index 0000000000..58848035b2 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/notifications.expected.jsonl @@ -0,0 +1,15 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-account","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-account","model":"deepseek-v4-flash","contextWindow":1000000}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[8],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Before sign-out"}],"source":{"kind":"model","provider":"deepseek-account","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true,"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Before sign-out"]}]},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":15,"time":0,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"hook","reason":"deepseek-account/signed-out"}}}}}} +{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/snapshots/sdk/account-provider-signout/result.expected.json b/snapshots/sdk/account-provider-signout/result.expected.json new file mode 100644 index 0000000000..126859b4d3 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/result.expected.json @@ -0,0 +1 @@ +{"sessionId":"{{sessionId}}","finalResponse":"Before sign-out"} diff --git a/snapshots/sdk/account-provider-signout/session.v3.jsonl b/snapshots/sdk/account-provider-signout/session.v3.jsonl new file mode 100644 index 0000000000..c4914849ec --- /dev/null +++ b/snapshots/sdk/account-provider-signout/session.v3.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":3,"id":"{{session:1}}","createdAt":1785097381464,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-account","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-account","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"session/title","data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Before sign-out"}],"source":{"kind":"model","provider":"deepseek-account","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"interrupted":true,"stream":[{"type":"chunk","time":1789643933994,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789643933994,"index":0,"dt":[],"texts":["Before sign-out"]}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"hook","reason":"deepseek-account/signed-out"}}}} diff --git a/snapshots/sdk/account-provider-signout/snapshot.yml b/snapshots/sdk/account-provider-signout/snapshot.yml new file mode 100644 index 0000000000..b5300fde63 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/snapshot.yml @@ -0,0 +1,10 @@ +version: 1 +scenario: account-provider-signout +profile: sdk +composition: sdk-account-provider-signout +recording: authored +header: + class: sdk-account-provider-signout + pin: true + systemPromptSource: text-turn + toolSchemasSource: text-turn diff --git a/snapshots/sdk/account-provider-signout/system-prompt.expected.md b/snapshots/sdk/account-provider-signout/system-prompt.expected.md new file mode 120000 index 0000000000..4ca0dd39b2 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/system-prompt.expected.md @@ -0,0 +1 @@ +../text-turn/system-prompt.expected.md \ No newline at end of file diff --git a/snapshots/sdk/account-provider-signout/tool-schemas.expected.json b/snapshots/sdk/account-provider-signout/tool-schemas.expected.json new file mode 120000 index 0000000000..11d7ccbf24 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/tool-schemas.expected.json @@ -0,0 +1 @@ +../text-turn/tool-schemas.expected.json \ No newline at end of file From cc478ac70a93806d28251b9450b234b79bd56bf0 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:59:31 +0800 Subject: [PATCH 02/30] feat(client,session-controller,llm): track available models and expose account settings --- apps/web/tests/default-model.e2e.ts | 15 +++- .../default-model/unselected.expected.md | 8 ++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 9 ++- docs/subsystems/core.zh.md | 9 ++- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 7 ++ docs/subsystems/session.zh.md | 7 ++ packages/api/remotes/src/remote-events.ts | 1 + .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../api/session-controller/src/catalog.ts | 14 +++- .../api/session-controller/src/commands.ts | 22 ++--- packages/api/session-controller/src/index.ts | 17 +++- packages/api/session-controller/src/types.ts | 2 +- .../tests/commands-upload-file.host.spec.ts | 1 + .../tests/session-cold.host.spec.ts | 1 + .../tests/session-models.host.spec.ts | 81 ++++++++++++------- .../session-controller/tests/test-remote.ts | 4 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/skeleton/ConversationContent.tsx | 4 +- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 8 +- .../client/ui-model-selection/README.zh.md | 8 +- .../src/client/directory.ts | 29 ++----- .../ui-model-selection/src/client/locales.ts | 4 +- .../ui-model-selection/src/client/service.ts | 8 +- .../tests/browser-plugin.client.spec.ts | 40 ++++----- .../tests/expected/unselected-model.txt | 1 + .../tests/model-select.client.spec.tsx | 9 +++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 3 + .../client/ui-settings-account/README.zh.md | 3 + .../ui-settings-account/src/client/index.ts | 14 +++- .../tests/apply.client.spec.ts | 55 +++++++++++++ .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../src/client/CustomProviderCard.tsx | 2 +- .../src/client/ModelsSection.tsx | 6 +- .../src/client/ProviderEditor.tsx | 9 ++- .../ui-settings-models/src/client/index.ts | 3 +- .../ui-settings-models/src/client/locales.ts | 2 + .../src/client/operations.ts | 6 ++ .../ui-settings-models/src/client/store.ts | 15 +++- .../tests/apply.client.spec.ts | 3 +- .../tests/components.client.spec.tsx | 23 +++++- .../tests/expected/deepseek-account-en.txt | 1 + .../tests/expected/deepseek-account-zh.txt | 1 + .../tests/model-list-catalog.client.spec.tsx | 1 + .../tests/onboarding-dialog.client.spec.tsx | 2 +- .../tests/provider-form.client.spec.tsx | 4 +- .../tests/store.client.spec.ts | 18 ++++- .../core/agent-default-model/README.i18n.yaml | 4 +- packages/core/agent-default-model/README.md | 6 +- .../core/agent-default-model/README.zh.md | 6 +- .../core/agent-default-model/src/index.ts | 39 +++++++-- .../tests/agent-default-model.spec.ts | 23 ++++-- .../extensions/tool-cordis/src/api-catalog.ts | 14 +++- 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 | 13 ++- packages/llm/llm-deepseek/src/index.ts | 1 + .../tests/account-routing.spec.ts | 17 ++++ .../llm/llm-deepseek/tests/adapter.spec.ts | 22 ++--- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/README.zh.md | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 9 ++- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 2 +- 77 files changed, 511 insertions(+), 189 deletions(-) create mode 100644 apps/web/tests/expected/default-model/unselected.expected.md create mode 100644 packages/client/ui-model-selection/tests/expected/unselected-model.txt create mode 100644 packages/client/ui-settings-account/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt create mode 100644 packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 304bdb97f6..f9fd8da7ea 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -17,7 +17,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' -import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' /** Points the shipped shared Agent default at this scenario's own route. */ @@ -135,10 +135,17 @@ describe('web e2e: the composer model switch is the default for later sessions', // default still names the route, and nothing serves it any more. // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every // stored profile in place. - await scaffold.ctx.settings.replace('llm-pi-ai', { providers: {} }) + await scaffold.ctx.settings.replace('llm-pi-ai', { providers: { + [START_ROUTE]: { displayName: 'Origin Gateway', api: 'openai-completions', + baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, + } }) await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false) - expect(await box.getAttribute('data-placeholder')).toBe('当前模型不可用,请先选择模型') + const unset = page.getByRole('button', { name: '请选择模型', exact: true }) + await unset.waitFor() + expect(await page.getByRole('button', { name: '发送消息', exact: true }).isEnabled()).toBe(false) + const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode()) // The block is an affordance; the refusal is the Host's. A client that // never disabled anything still cannot start a turn on a dead route. @@ -151,7 +158,7 @@ describe('web e2e: the composer model switch is the default for later sessions', // The way out stays open. Locking the model seat with everything else // would leave the composer asking for the one thing it prevents. - const seat = page.getByRole('button', { name: /^选择模型/ }) + const seat = page.getByRole('button', { name: '请选择模型', exact: true }) expect(await seat.isEnabled()).toBe(true) await seat.click() await page.getByRole('menuitem', { name: /模型/ }).click() diff --git a/apps/web/tests/expected/default-model/unselected.expected.md b/apps/web/tests/expected/default-model/unselected.expected.md new file mode 100644 index 0000000000..c4fb007e9c --- /dev/null +++ b/apps/web/tests/expected/default-model/unselected.expected.md @@ -0,0 +1,8 @@ +- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" [disabled] +- button "添加文件或调用指令" [disabled]: + - img +- button "访问模式,当前:工作区内修改" [disabled]: 工作区内修改 +- button "请选择模型": + - text: 请选择模型 + - img +- button "发送消息" [disabled] diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 72ffc23a6d..e174fcd03e 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 3d5ad12ef85b6b3c4fedde87cea8963f4a20d93b -event-producer-consumer.zh.md: c212611c25f60283f91ef58530121d84092be936 +event-producer-consumer.md: c5535c610ed88f265efe5065e2ff3374030192d4 +event-producer-consumer.zh.md: b2e558f5e711a3cf3628f1b9da802c1d69693090 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3d5ad12ef8..c5535c610e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,7 +37,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:398`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:368`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c212611c25..b2e558f5e7 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -39,7 +39,7 @@ | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:398`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:368`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 23bd8abfb0..d7679f8cfd 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: ad5bb66073bc7c3381739935eff593d49343b851 -core.zh.md: d0ec1aed5f3d504e4cd5e781c3a6927f311cbc7b +core.md: 5b51a81ff910072e67ffb796cbc9276a00827dcc +core.zh.md: 8c2fd014203500990b29ea374c45fad2f4ef2b42 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index ad5bb66073..5b51a81ff9 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -434,11 +434,18 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings - * provider keeps its composition entry. + * provider retains the selection for this process. * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise + +/** + * Store the initial credential setup choice only before a user selection exists. + * @param next - available model belonging to the provider the user initialized. + * @returns after the initial selection is saved, or immediately if already selected. + */ +async initializeSelection(next: ModelSelection): Promise ``` Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index d0ec1aed5f..8c2fd01420 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -444,11 +444,18 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings - * provider keeps its composition entry. + * provider retains the selection for this process. * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise + +/** + * Store the initial credential setup choice only before a user selection exists. + * @param next - available model belonging to the provider the user initialized. + * @returns after the initial selection is saved, or immediately if already selected. + */ +async initializeSelection(next: ModelSelection): Promise ``` Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 83eecfd1c7..84327833d3 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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 docs/subsystems/session.md -session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149 -session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a +session.md: 202f6adf6e89e1293feb5181c1397b4725720ced +session.zh.md: feb2cc0845b426bad0046d0a8a7a4737b210a8f4 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 62f7785d4d..202f6adf6e 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -818,6 +818,13 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Initialize the default after the user first configures a provider credential. + * @param provider - the provider whose credential was configured. + * @returns after saving the initial available model; existing user choices are retained. + */ +@Remote async initializeDefaultModel(provider: string): Promise + /** * Describe every currently routable model for Host-generation selectors. * @returns provider-grouped models, the deployment default, and isolated provider failures. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7cd3c38162..feb2cc0845 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -822,6 +822,13 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Initialize the default after the user first configures a provider credential. + * @param provider - the provider whose credential was configured. + * @returns after saving the initial available model; existing user choices are retained. + */ +@Remote async initializeDefaultModel(provider: string): Promise + /** * Describe every currently routable model for Host-generation selectors. * @returns provider-grouped models, the deployment default, and isolated provider failures. diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 115cbff692..b9cecc5b13 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -24,6 +24,7 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'api-session/removed', mode: 'emit' }, { event: 'api-session/status', mode: 'emit' }, { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/record-updated', mode: 'emit' }, { event: 'credentials/reference-updated', mode: 'emit' }, { event: 'goal/activation-changed', mode: 'emit' }, { event: 'cordis/request-run', mode: 'emit' }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index d04576c269..b178f956cf 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: a0e18910d14f879171a1415476a7e05a0d261a8a -README.zh.md: 58466e387fd604137a2358e8b9a7de3320691034 +README.md: 5f09de61395c3d729144203b77eeb299c217128e +README.zh.md: dfb5b97b5804b4512d17eab47866ee079fbc047d diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index a0e18910d1..5f09de6139 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,6 +68,8 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. Availability never substitutes another model or rewrites a Session selection. + ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 58466e387f..dfb5b97b58 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,6 +68,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。不可用的选择在投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。可用性变化不会替换模型或改写会话选择。 + ----- diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts index 0c97107f03..df751d86ba 100644 --- a/packages/api/session-controller/src/catalog.ts +++ b/packages/api/session-controller/src/catalog.ts @@ -59,9 +59,21 @@ export async function buildModelCatalog( })) return { default: { ...defaultSelection }, - routableProviders: providers.map(provider => provider.id), + routableProviders: catalog.flatMap(item => item.kind === 'group' && item.group.models.length > 0 ? [item.group.id] : []), groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) .filter(group => group.models.length > 0), failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), } } + +/** + * Check a GUI selection against the current available provider catalog. + * @param ctx - Host LLM registry. + * @param selection - stored or explicitly requested selection. + * @returns whether the exact model is currently advertised as available. + */ +export async function modelAvailable(ctx: Context, selection: ModelSelection): Promise { + if (!ctx.llm.listProviders().some(provider => provider.id === selection.provider)) return false + const models = await ctx.llm.listModels(selection.provider) + return models.some(model => model.id === selection.model) +} diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 03c697734b..e73659d4cb 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -1,5 +1,6 @@ /** Session commands whose activation policy is explicit at each Remote method. */ +import { modelAvailable } from './catalog.ts' import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' @@ -151,6 +152,7 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) return this.agents.serializeImageAdmission(agent, async () => { try { + await this.requireModel(request) const resolved = await this.ctx.llm.resolveCallConfig({ provider: request.provider, model: request.model, @@ -329,13 +331,7 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } const selection = this.agents.selectionFor(agent).current - if (!routeServed(this.ctx, selection.provider)) { - throw new RemoteError( - 'session/model-unavailable', - `no adapter serves provider "${selection.provider}"; select a model for this session`, - { provider: selection.provider, model: selection.model }, - ) - } + await this.requireModel(selection) const source: MessageSource = { kind: 'user', rpcId: request.requestId, @@ -361,6 +357,7 @@ export class SessionCommandController { ) const content = await this.ctx.attachments.admitPromptContent(admission.content) const message: UserMessage = createUserMessage({ content, source }) + await this.requireModel(this.agents.selectionFor(agent).current) if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', @@ -384,6 +381,13 @@ export class SessionCommandController { return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() } + private async requireModel(selection: Pick): Promise { + if (!await modelAvailable(this.ctx, selection)) { + throw new RemoteError('session/model-unavailable', 'Select an available model before sending a message.', + { provider: selection.provider, model: selection.model }) + } + } + /** * Read one durable image after proving the Session log references it. * @param request - Session and attachment identities used for authorization. @@ -690,7 +694,3 @@ function referencedImage( } return undefined } - -function routeServed(ctx: Context, provider: string): boolean { - return ctx.llm.listProviders().some(entry => entry.id === provider) -} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b6f5f0dfa0..6f51c15da4 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { errorChain } from '@deepseek-ai/dsh-llm' +import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -270,6 +270,21 @@ export class SessionController extends TypertRemoteService { return this.commands.selectModel(request) } + /** + * Initialize the default after the user first configures a provider credential. + * @param provider - the provider whose credential was configured. + * @returns after saving the initial available model; existing user choices are retained. + */ + @Remote + async initializeDefaultModel(provider: string): Promise { + const catalog = await buildModelCatalog(this.ctx) + const model = catalog.groups.find(group => group.id === provider)?.models[0] + if (model === undefined) throw new Error(`provider "${provider}" has no available models`) + await this.ctx.agentDefaultModel.initializeSelection({ provider, model: model.id, + ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }, + }) + } + /** * Describe every currently routable model for Host-generation selectors. * @returns provider-grouped models, the deployment default, and isolated provider failures. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a925e9da83..c5b92ec0cc 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -143,7 +143,7 @@ export interface ModelCatalogFailure { /** Host-generation model catalog and the default used by unconfigured Sessions. */ export interface ModelCatalog { readonly default: ModelSelection - /** Provider routes currently able to serve a request, including empty catalogs. */ + /** Provider routes with at least one currently available catalog model. */ readonly routableProviders: readonly string[] readonly groups: readonly ModelProviderGroup[] readonly failures: readonly ModelCatalogFailure[] diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 4ff61a9b9a..14527cccec 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -83,6 +83,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ }, } as never) ctx.provide('llm', { + listModels: async () => [{ id: 'fixture-model', name: 'Model' }], listProviders: () => [{ id: 'fixture', name: 'Fixture' }], resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), } as never) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index c8be6f2252..8995fa87a1 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -816,6 +816,7 @@ describe('sessions.prompt synchronous rejection', () => { AttachmentStore.prototype, ) as never) ctx.provide('llm', { + listModels: async () => [{ id: 'm', name: 'Model' }], listProviders: () => [{ id: 'p', name: 'Provider' }], resolveModelInfo: () => Promise.resolve({ provider: 'p', id: 'm', name: 'Model', inputModalities: ['text', 'image'], diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 51133aafde..26d71d839e 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -1,7 +1,7 @@ /** * Session Controller model-directory and selection behavior: dynamic provider grouping, * provider-local catalog failures, logged-selection restoration without stale - * catalog injection, advisory pass-through models, and the prompt-assembly + * catalog injection, unavailable-model refusals, and the prompt-assembly * boundary for a running selection change. */ @@ -23,7 +23,8 @@ import { ApiSessionAgentController } from '../src/agent.ts' import { buildModelCatalog } from '../src/catalog.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { createSessionTestRemote } from './test-remote.ts' +import AgentDefaultModel from '@deepseek-ai/dsh-agent-default-model' +import { createSessionTestController, createSessionTestRemote } from './test-remote.ts' function request

(payload: P): P { return payload @@ -146,7 +147,7 @@ function registerTextOnly(ctx: Context): void { override resolveModel(provider: string, model: string): Promise { return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) } - }('Text Only', [])) + }('Text Only', [{ provider: 'text-only', id: 'plain', name: 'Plain' }])) } /** Resolve the Client-visible next selection from durable state and the Host default. */ @@ -266,7 +267,6 @@ describe('Web session model selection', () => { ], })) expect(result.ok).toBe(true) - expect(followup).not.toHaveBeenCalled() expect((steer.mock.calls[0]?.[0] as UserMessage).content).toEqual([ { type: 'text', text: 'look at this' }, { @@ -347,6 +347,23 @@ describe('Web session model selection', () => { expect(readImage).toHaveBeenCalledOnce() await ctx.fiber.dispose() }) + it('initializes only the first explicitly configured provider and retains later user choices', async () => { + const { ctx } = await harness() + await ctx.plugin(AgentDefaultModel, { provider: 'missing', model: 'missing' }) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', + }) + await controller.initializeDefaultModel('deepseek-official') + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ + provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high', + }) + await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' }) + await controller.initializeDefaultModel('deepseek-official') + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' }) + await expect(controller.initializeDefaultModel('empty')).rejects.toThrow('no available models') + await ctx.fiber.dispose() + }) + it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', @@ -428,7 +445,7 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { + it('rejects unlisted models and switches available models only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } @@ -440,12 +457,12 @@ describe('Web session model selection', () => { const selected = expectValue(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'max', }))) expect(selected.selected).toEqual({ provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'max', }) await expect(agentEvents(ctx, agent).waterfall( @@ -453,26 +470,26 @@ describe('Web session model selection', () => { )).resolves.toEqual(seed) expect((await ctx.systemPrompt.assemble()).variables) - .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' }) + .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) await expect(agentEvents(ctx, agent).waterfall( 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'max', }) const unsupported = await remote.selectModel(request({ sessionId, provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'medium', })) expect(unsupported).toMatchObject({ ok: false, error: { code: 'session/model-unavailable', - message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"', + message: 'provider "deepseek-official" model "deepseek-reasoner" does not support reasoning effort "medium"', }, }) @@ -485,7 +502,7 @@ describe('Web session model selection', () => { ok: false, error: { code: 'session/model-unavailable', - message: 'no adapter registered for provider "missing"', + message: 'Select an available model before sending a message.', details: { provider: 'missing', model: 'model' }, }, }) @@ -496,13 +513,13 @@ describe('Web session model selection', () => { }))).toMatchObject({ ok: false, error: { - code: 'gateway/internal', - message: 'fixture rejected the selection', - details: {}, + code: 'session/model-unavailable', + message: 'Select an available model before sending a message.', + details: { provider: 'remote-rejected', model: 'model' }, }, }) expect(currentSelection(ctx, sessionId)) - .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' }) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) @@ -614,15 +631,23 @@ describe('Web session model selection', () => { const unavailableCatalog = await buildModelCatalog(ctx) expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false) - // An advisory-unlisted model on a live route is NOT this: the route - // serves it, so the prompt goes through and nothing blocks. - expectValue(await remote.selectModel(request({ + expect(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'unlisted-but-served', - }))) - const catalog = await buildModelCatalog(ctx) - expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true) - expect(catalog.groups.flatMap(group => group.models.map(model => model.id))) - .not.toContain('unlisted-but-served') + }))).toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' }) + await ctx.fiber.dispose() + }) + + it('refuses a removed model while its provider still serves other models without changing the log', async () => { + const { ctx, sessionId, agent } = await harness({ provider: 'deepseek-official', model: 'removed-model' }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', + }) + const events = [...agent.session.snapshotEvents()] + expect(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'hi' }] }))) + .toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + expect(agent.session.snapshotEvents()).toEqual(events) + expect(currentSelection(ctx, sessionId)).toMatchObject({ provider: 'deepseek-official', model: 'removed-model' }) await ctx.fiber.dispose() }) @@ -654,13 +679,13 @@ describe('Web session model selection', () => { provider, id: model, name: model, inputModalities: ['text', 'image'], }) } - }('Image Capable', [])) + }('Image Capable', [{ provider: 'image-capable', id: 'vision', name: 'Vision' }])) ctx.llm.registerAdapter(['string-error'], new class extends CatalogAdapter { override resolveModel(): Promise { // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario. return Promise.reject('string selection failure') } - }('String Error', [])) + }('String Error', [{ provider: 'string-error', id: 'plain', name: 'Plain' }])) let saveMode: 'success' | 'error' | 'remote' = 'success' const savedRef = { attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, @@ -725,12 +750,12 @@ describe('Web session model selection', () => { sessionId, provider: 'image-capable', model: 'vision', }))) expect(await remote.selectModel(request({ - sessionId, provider: 'metadata-broken', model: 'broken', + sessionId, provider: 'metadata-broken', model: 'listed', }))).toMatchObject({ ok: false, error: { code: 'session/model-unavailable', message: 'reasoning metadata offline' }, }) expect(await remote.selectModel(request({ - sessionId, provider: 'string-error', model: 'broken', + sessionId, provider: 'string-error', model: 'plain', }))).toMatchObject({ ok: false, error: { code: 'session/model-unavailable', message: 'string selection failure' }, diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index b4d3db8198..68e5f9f779 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -248,6 +248,10 @@ function installControllers( } if (ctx.get('llm') === undefined) { ctx.provide('llm', { + listModels: async () => { + const selection = defaults.defaultModelSelection() + return [{ id: selection.model, name: selection.model }] + }, listProviders: () => { const selection = defaults.defaultModelSelection() return [{ id: selection.provider, name: selection.provider }] diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 74cd3168df..f6eaa6d8b4 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: fef3a6551b517af2ac636687883864525aac70ee -README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5 +README.md: 7be555c8866a7d69631888be5f6383f8ec0a772a +README.zh.md: 74a4d083af5f2bb0ca8ea9a12cd3679c4860aaf1 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fef3a6551b..7be555c886 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -60,7 +60,7 @@ The shell reads the persisted View preference before rendering when a Session fi Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([handle styles](src/client/skeleton/ConversationRoot.module.css)). -The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. Model availability blocks retain the normal input placeholder while leaving the model picker enabled. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e9ab08c686..74a4d083af 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -60,7 +60,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([拖动手柄样式](src/client/skeleton/ConversationRoot.module.css))。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 模型不可用导致的阻塞保留常规输入占位文案,模型选择器仍可操作。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index adace1c113..42bc9724df 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -133,7 +133,7 @@ export function ConversationContent(props: ConversationContentProps) { // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, // not a different tree, so the textarea DOM survives the transition. const inert = sessionId === undefined || (hero && chipTitle === undefined) - // A raised block is the same inert posture with the blocker's own reason: + // A raised block keeps the same textarea mounted: // one disabled textarea, never a second tree. The no-workspace state wins // when both hold — picking a workspace is the earlier prerequisite. const blocked = !inert && composerBlock !== undefined @@ -150,7 +150,7 @@ export function ConversationContent(props: ConversationContentProps) { // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the // user clears it. - ? { blocked: composerBlock, placeholder: composerBlock.reason } + ? { blocked: composerBlock, ...(hero ? { placeholder: t('placeholder.hero') } : {}) } : hero ? { placeholder: t('placeholder.hero') } : {}), }) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 4ca0dee8fc..9ba09cab47 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/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/client/ui-model-selection/README.md -README.md: 96bf249b8976ad56b5abb70975635fa452c700f8 -README.zh.md: 71eb0de85bd87e5b2b92bf33d19f925e649f92a7 +README.md: 7afc8f158509e18e90236d6302ab51125fa7a891 +README.zh.md: c14ad7ab05af24c35727d42e6d843cbdaf20f332 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 96bf249b89..7afc8f1585 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If no adapter can serve the session's route, the composer remains disabled until routing becomes available. +The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If the selected model is unavailable, the composer stays disabled until the user selects an available model or that exact model becomes available again. ## Table of Contents @@ -27,7 +27,7 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses DeepSeek account and API-key routes appear as separate provider groups, each exposing the same configured model catalog. -Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`. +Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row clears the effective selection and the trigger prompts `Select model`, without changing the stored choice. ### Model and effort @@ -35,7 +35,7 @@ Models stay grouped by provider. The composer menu shows model and effort names ### Unroutable sessions -When the Host reports that no adapter serves the session's route, this plugin raises a composer block and the input goes inert with its own copy; recovering clears it without a reload. A `null` before the first load or after one failed never blocks, and catalog membership never blocks either — a route serving a model it does not advertise is missing from the groups yet usable. +The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block. @@ -51,7 +51,7 @@ When another writer owns the Session, model-selection failures tell the user to

Implementation internals — click to expand -Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events. +Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `settings/document-updated`, and credential update events.
diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 71eb0de85b..c14ad7ab05 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。如果没有适配器可以服务会话路由,composer 会保持停用,直至路由恢复可用。 +Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。 ## 目录 @@ -27,7 +27,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示相同的已配置模型目录。 -与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。 +与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,有效选择置空,触发器提示“请选择模型”,已保存的选择不变。 ### 模型与推理强度 @@ -35,7 +35,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 不可路由的会话 -当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。 +确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。 @@ -51,7 +51,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
实现细节——点击展开 -两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的建议目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated` 与 `settings/document-updated` owner 事件上重拉。 +两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`settings/document-updated` 与凭据更新事件上重拉。
diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index b27811f501..8e03bcf206 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -15,15 +15,9 @@ import type { ModelCatalogDirectory } from './catalog.ts' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Effective selection: durable next-request projection, then Host default. */ + /** Available durable selection, or the available default for an unselected Session. */ current: ModelSelection | null - /** - * Whether an adapter serves the current selection's provider, as the host reports - * it — null before the first load, which is NOT the same as blocked. Read - * this rather than "current matches no group": catalog membership is - * advisory, so a route serving a model it stopped advertising is missing - * from the groups yet perfectly usable. - */ + /** Whether the current selection is present in the available catalog; null while unresolved. */ routable: boolean | null /** Successfully loaded provider groups (last good load). */ groups: readonly ModelProviderGroup[] @@ -45,7 +39,6 @@ export class ModelDirectory { /** Latest selection operation wins; an older response never overwrites a newer one. */ private generation = 0 private disposed = false - private resolved = false private readonly unsubscribeCatalog: () => void private readonly unsubscribeSelection: () => void @@ -69,7 +62,7 @@ export class ModelDirectory { } /** - * Ensure the Host generation's shared advisory catalog is loaded. + * Ensure the Host generation's shared available catalog is loaded. * @returns the fresh directory value. */ async load(): Promise { @@ -144,15 +137,6 @@ export class ModelDirectory { const catalog = this.catalog.store.getSnapshot() const projected = modelSelectionProjection(this.projected.getSnapshot()) if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { - if (this.resolved) { - if (catalog.status === 'error') { - this.store.update((state) => { - state.status = 'error' - state.error = catalog.error - }) - } - return - } this.store.set({ current: null, routable: null, @@ -163,11 +147,12 @@ export class ModelDirectory { }) return } - const current = projected.next ?? catalog.value.default - this.resolved = true + const intended = projected.next ?? catalog.value.default + const current = catalog.value.groups.some(group => group.id === intended.provider + && group.models.some(model => model.id === intended.model)) ? intended : null this.store.set({ current, - routable: catalog.value.routableProviders.includes(current.provider), + routable: current !== null, groups: catalog.value.groups, failures: catalog.value.failures, status: this.store.getSnapshot().status === 'selecting' diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 2e573796ea..2ab727b353 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -15,9 +15,9 @@ export const zh = { 'option.loadError': '目录加载失败:{message}', 'option.deepseekV4Flash.description': '快速、高效且经济;适合目标明确、常规或并行任务。', 'option.deepseekV4Pro.description': '更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。', - 'trigger.fallback': '选择模型', + 'trigger.fallback': '请选择模型', 'trigger.loading': '正在加载模型…', - 'trigger.selectAria': '选择模型', + 'trigger.selectAria': '请选择模型', 'trigger.aria': '选择模型,当前 {model}', 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', 'menu.aria': '模型与推理等级', diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index d92c2c08b3..cee8e630d1 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -57,6 +57,7 @@ export class ModelDirectoryResolver extends Service { }) ctx.remote.$on('llm/adapters-updated', () => { this.catalog.refresh() }) ctx.remote.$on('settings/document-updated', () => { this.catalog.refresh() }) + ctx.remote.$on('credentials/record-updated', () => { this.catalog.refresh() }) ctx.remote.$on('credentials/reference-updated', () => { this.catalog.refresh() }) } @@ -83,16 +84,11 @@ export class ModelDirectoryResolver extends Service { binding.session.projections.faceOf('modelSelection'), ) live.directories.set(binding, directory) - // The composer cannot read this plugin (the dependency runs one way), so - // the block is pushed: the Host says whether an adapter serves the - // session's route, and only a definite `false` makes the input inert. - // `null` — before the first load, or after one failed — must not, or a - // slow or unreachable Host would lock a working composer. const conversation = this.ctx.get('conversation') if (conversation !== undefined) { const publish = (): void => { if (sessions.binding(sessionId) !== binding) return - conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false + conversation.blocks.set(sessionId, directory.store.getSnapshot().routable !== true ? { reason: this.blockReason() } : undefined) } diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index ecae6a1281..67484a5d95 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -83,7 +83,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { value: { default: defaultSelection, routableProviders: routable ? ['deepseek-official'] : [], - groups: GROUPS, + groups: routable ? GROUPS : [], failures: [], }, }) @@ -299,7 +299,7 @@ describe('ui-model-selection dual entry', () => { expect(b.calls.models).toBe(1) }) - it('keeps the durable projected selection while the eager catalog reconnects', async () => { + it('hides the effective selection until the reconnected catalog validates it', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) @@ -308,17 +308,17 @@ describe('ui-model-selection dual entry', () => { b.ctx.emit('connection/reset') expect(face.directory.getSnapshot()).toMatchObject({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, - status: 'ready', + current: null, + status: 'loading', }) face.load() expect(face.directory.getSnapshot()).toMatchObject({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, - status: 'ready', + current: null, + status: 'loading', }) }) - it('keeps the last complete view while a refreshed catalog catches up with projection', async () => { + it('blocks until a refreshed catalog validates the projected selection', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) @@ -331,8 +331,8 @@ describe('ui-model-selection dual entry', () => { next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, }) expect(face.directory.getSnapshot()).toMatchObject({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - status: 'ready', + current: null, + status: 'loading', }) await vi.waitFor(() => { @@ -415,13 +415,11 @@ describe('ui-model-selection dual entry', () => { } }) - it('blocks the composer only once the Host reports the route unservable', async () => { + it('blocks the composer when current catalog models disappear', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) - // Before the first load nothing is known. `null` is not `false`: a slow - // or unreachable Host must never lock a working composer. expect(b.blockOf('s1')).toBeUndefined() face.load() await Promise.resolve() @@ -445,20 +443,16 @@ describe('ui-model-selection dual entry', () => { expect(b.calls.models).toBe(3) }) - it('never blocks on catalog membership alone', async () => { + it('hides an unavailable durable selection without replacing or rewriting it', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) - // A model the route serves but no longer advertises: the seat prompts for - // a selection, the composer stays usable. Blocking here would break a - // supported configuration (a narrowed `models` list over a live route). - b.setHostCurrent({ provider: 'deepseek-official', model: 'unlisted' }) - face.load() - await Promise.resolve() - await Promise.resolve() - const snapshot = face.directory.getSnapshot() - expect(snapshot.groups.flatMap(group => group.models.map(model => model.id))).not.toContain('unlisted') - expect(b.blockOf('s1')).toBeUndefined() + const intended = { provider: 'deepseek-official', model: 'unlisted' } + b.setProjected(sid('s1'), { lastUsed: intended, next: intended }) + await vi.waitFor(() => { expect(face.directory.getSnapshot().status).toBe('ready') }) + expect(face.directory.getSnapshot().current).toBeNull() + expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) + expect(b.calls.select).toBe(0) }) it('clears its block when the session scope goes', async () => { diff --git a/packages/client/ui-model-selection/tests/expected/unselected-model.txt b/packages/client/ui-model-selection/tests/expected/unselected-model.txt new file mode 100644 index 0000000000..d6d4beb3b1 --- /dev/null +++ b/packages/client/ui-model-selection/tests/expected/unselected-model.txt @@ -0,0 +1 @@ +请选择模型 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 4a5ee43b91..3ac3613aac 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -446,3 +446,12 @@ describe('ModelSelect keyboard walk', () => { expect(document.activeElement).toBe(rows[0]) }) }) + +it('shows the unselected model control without a stale model or effort', async () => { + const directory = createSnapshotStore(state({ current: null, routable: false })) + render() + const trigger = screen.getByRole('button', { name: '请选择模型' }) + expect(trigger.hasAttribute('disabled')).toBe(false) + await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') + expect(trigger.textContent).not.toContain('High') +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 3e8fa7838f..a9fcd71d5a 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/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/client/ui-settings-account/README.md -README.md: e9df6c0f3dd20d47c688d3ca23b6cff88ab72e99 -README.zh.md: 775d518b1c0e75f7fbd191bc21e57ac24c7f8179 +README.md: d54687d083897842518a0c95dfc84d39e985f3a1 +README.zh.md: c4dbcf234fb72e826ccd742b70740744e98e8fcb diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index e9df6c0f3d..d54687d083 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -50,6 +50,9 @@ Account login uses a dismissible dialog before the model onboarding credential e The balance card shows recharge funds and positive bonus credit in separate rows. Empty or nonpositive bonus wallets hide the bonus row and its divider; currencies retain their own amounts. + +A successful first login initializes the default model from `deepseek-account` when no user choice has been saved. Later logins retain the saved choice, including an unavailable one. + ## Model Experience diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 775d518b1c..c4dbcf234f 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -50,6 +50,9 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 余额卡片分行展示充值余额与正额赠金余额。赠送钱包为空或没有正余额时,隐藏赠金行及其分隔线;不同币种分别显示。 + +首次成功登录且用户尚未保存模型选择时,从 `deepseek-account` 初始化默认模型。后续登录保留已保存的选择,包括不可用的选择。 + ## 模型体验 diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index a5b1c715ab..da26bbd8d8 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -22,7 +22,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { 'settings.account': AccountKey } } /** Services required by account settings. */ -export const inject = ['slots', 'locale', 'remote', 'remote.account'] +export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session'] /** @param ctx - browser plugin context. */ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register('settings.account', { en, zh }), 'account: dictionaries') @@ -72,7 +72,17 @@ export function apply(ctx: Context): void { for await (const frame of stream) { revision++ refreshing = undefined - publish({ ...snapshot, view: frame.value, details: undefined, failed: false }) + let initializationFailed = false + if (frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded') { + try { + const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') + initializationFailed = !initialized.ok + } catch (_error) { + initializationFailed = true + } + } + publish({ ...snapshot, view: frame.value, details: undefined, failed: initializationFailed }) browser.update(frame.value) frame.accept() void refresh() diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts new file mode 100644 index 0000000000..7f01fc5f6e --- /dev/null +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/** Account state updates continue after default-model initialization refuses. */ +import { expect, it, vi } from 'vitest' +import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' +import { apply, type AccountSectionInjected } from '../src/client/index.ts' + +it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { + const abort = new AbortController() + const done = Promise.withResolvers() + const release = Promise.withResolvers() + const initializeDefaultModel = vi.fn(async () => { + if (outcome === 'disconnected') throw new Error('connection lost') + return { ok: outcome === 'accepted', value: undefined } + }) + const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } + const signedIn: AccountView = { + status: 'credential-stored', links, attempt: { id: 'login' as SignInAttemptId, phase: 'succeeded' }, + } + const frames: AccountView[] = [signedIn, signedIn, { status: 'signed-out', links, attempt: null }] + const disposers: (() => void)[] = [] + let operations: AccountSectionInjected | undefined + const ctx = { + effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) disposers.push(dispose) }, + locale: { register: () => () => {}, bind: () => (key: string) => key }, + slots: { + inject: (_name: string, install: () => (() => void) | undefined) => { + const dispose = install() + if (dispose) disposers.push(dispose) + }, + register: (entry: { inject?: () => AccountSectionInjected }) => { + if (entry.inject) operations = entry.inject() + return () => {} + }, + }, + remote: { + session: { initializeDefaultModel }, + account: { getProfile: async () => ({ ok: true, value: null }), getBalance: async () => ({ ok: true, value: null }) }, + $stream: () => ({ + signal: abort.signal, dispose: () => { abort.abort(); release.resolve(undefined) }, + async *[Symbol.asyncIterator]() { + for (const value of frames) yield { value, accept: () => { if (value.status === 'signed-out') done.resolve(undefined) } } + await release.promise + }, + }), + }, + } + try { + apply(ctx as never) + await done.promise + expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account') + expect(operations?.hooks.account.getSnapshot()).toMatchObject({ view: { status: 'signed-out' }, failed: false }) + } finally { + for (const dispose of disposers.reverse()) dispose() + } +}) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 59be36dcbd..c84f99abf1 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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/client/ui-settings-models/README.md -README.md: 19cf77c41f3c391622945a8eb28ab2bfa104f270 -README.zh.md: 47d67039ab7fcdf7ca8e0781498aa795dac7e002 +README.md: ccce6981a618fd2dfa451f1600ae19e443d77973 +README.zh.md: 0d2f64a1fe4583493b192f33f128f86157a65f8f diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 19cf77c41f..ccce6981a6 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -55,6 +55,8 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. Account availability follows the stored account credential. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. + ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 47d67039ab..0d2f64a1fe 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -55,6 +55,8 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用性取决于已保存的账号凭据。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 + ----- diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 4f382e92a1..c84e72f743 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -178,7 +178,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // and the retry above now goes straight back to this write. if (stored !== undefined) return stored } - return undefined + return operations.initializeModel(route.trim()) } const create = async (): Promise => { diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index a9ce163a50..c61b380a25 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -139,7 +139,7 @@ export async function removeProviderProfile( * @returns whether to render the setup card. */ export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean { - if (anyUsable) return false + if (anyUsable || row.entry.provider === 'deepseek-account') return false if (row.entry.settingsPath.length > 0) return false return row.credential?.configured !== true } @@ -203,7 +203,9 @@ export function ModelsSection(props: ModelsSectionProps): ReactNode { function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode { const { controller, operations, schema, t } = injected - const state = injected.useSnapshot(snapshot => snapshot) + const snapshot = injected.useSnapshot(value => value) + const state = { ...snapshot, rows: snapshot.rows.map(row => row.entry.provider === 'deepseek-account' + ? { ...row, entry: { ...row.entry, displayName: t('deepSeekAccount') } } : row) } const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) const [deleteTarget, setDeleteTarget] = useState(undefined) diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index bc89f43fc8..93e1fa7d1e 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -173,6 +173,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const fallback = schema.getPath(namespace.value, settingsPath) const disabled = props.readOnly || busy const layout = layoutOf(namespace.ns) + const accountProvider = props.provider === 'deepseek-account' const keyRef = refFor(schema, namespace, settingsPath, props.provider) // The same schema read the create card makes, so the choices offered here // and there cannot drift apart: both come from the adapter's own `Config`. @@ -184,6 +185,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ) useEffect(() => { + if (accountProvider) return let stale = false setKeyState(undefined) // The key state is a placeholder hint, not a precondition for editing: a @@ -193,7 +195,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { setKeyState(described) }) return () => { stale = true } - }, [operations, keyRef]) + }, [operations, keyRef, accountProvider]) const stringAt = (source: unknown, key: string): string | undefined => { const value = schema.getPath(source, [key]) @@ -286,6 +288,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (keyValue.length > 0) { const stored = await operations.storeCredential(keyRef, keyValue) if (stored !== undefined) return stored + const initialized = await operations.initializeModel(props.provider) + if (initialized !== undefined) return initialized } setKeyDraft('') return undefined @@ -358,6 +362,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }, onReset: () => { setDraft(current => schema.deletePath(current, ['models'])) }, } + if (accountProvider) return return ( <>
diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index ddce8c9e49..9ce0e71534 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -64,7 +64,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * constrained; registration depends on each slot through `slots.inject()`. */ export const inject = [ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'remote.session', 'settingsScope', 'settingsSchema', ] @@ -125,6 +125,7 @@ export function apply(ctx: ClientContext): void { const refreshModels = (): void => { refreshIfLoaded(controller) } const disposers = [ ctx.remote.$on('settings/document-updated', () => { refreshModels() }), + ctx.remote.$on('credentials/record-updated', refreshModels), ctx.remote.$on('credentials/reference-updated', refreshModels), ctx.remote.$on('llm/adapters-updated', refreshModels), ctx.on('connection/reset', refreshModels), diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 2b0f92d8df..1bafbdc514 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -3,6 +3,7 @@ /** English strings (the key-set source of truth for this pair). */ export const en = { nav: 'Models', + deepSeekAccount: 'DeepSeek Account', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', edit: 'Edit', @@ -115,6 +116,7 @@ export type ModelsKey = keyof typeof en /** Chinese strings (same keys as {@link en}). */ export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', + deepSeekAccount: 'DeepSeek 账号', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', edit: '编辑', diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 8ca6beb798..75d87ac821 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -32,6 +32,8 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { + /** @param provider - newly configured provider. @returns refusal text, or undefined after initialization. */ + initializeModel(provider: string): Promise /** * Read one credential reference's state. * @param ref - credential reference name. @@ -81,6 +83,10 @@ export interface ModelsOperations { */ export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { + initializeModel: async (provider) => { + const response = await ctx.remote.session.initializeDefaultModel(provider) + return response.ok ? undefined : response.error.message + }, describeCredential: async (ref) => { const response = await ctx.remote.credentials.describe([ref]) return response.ok ? response.value[ref] : undefined diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 2af1d54d5c..f3e948b79f 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -69,6 +69,8 @@ export function joinProviderDirectory( /** One provider row the page renders. */ export interface ProviderRow { + /** Account route has usable credentials for the configured inference origin. */ + accountAvailable?: boolean /** The directory entry (route id, display name, settings address, live state). */ entry: ProviderDirectoryEntry /** Whether any layer configures this provider (its profile resolves). */ @@ -208,11 +210,18 @@ export class ModelsSettingsStore { entry, configured, removable, - apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath, this.schema), + apiKeyEnv: entry.provider === 'deepseek-account' ? undefined : apiKeyEnvOf(namespace, entry.settingsPath, this.schema), credential: undefined, } }) - const refs = [...new Set(rows.map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))] + if (rows.some(row => row.entry.provider === 'deepseek-account')) { + const catalog = await this.ctx.remote.session.modelCatalog() + for (const row of rows) { + if (row.entry.provider === 'deepseek-account') row.accountAvailable = catalog.ok + && catalog.value.groups.some(group => group.id === 'deepseek-account' && group.models.length > 0) + } + } + const refs = [...new Set(rows.filter(row => row.entry.provider !== 'deepseek-account').map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))] let credentials: Record = {} let credentialError: string | null = null if (refs.length > 0) { @@ -230,6 +239,7 @@ export class ModelsSettingsStore { s.credentialError = credentialError s.writable = writable s.rows = rows.map((row) => { + if (row.entry.provider === 'deepseek-account') return row const named = row.apiKeyEnv === undefined ? undefined : credentials[row.apiKeyEnv] const derived = row.apiKeyEnv !== undefined ? undefined : credentials[deriveKeyRef(row.entry.provider)] return { @@ -264,6 +274,7 @@ export class ModelsSettingsStore { */ export function providerUsable(row: ProviderRow): boolean { if (!row.entry.active) return false + if (row.entry.provider === 'deepseek-account') return row.accountAvailable === true if (row.apiKeyEnv === undefined) return true return row.credential?.configured === true } diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 22e1d04f06..408de5248f 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -45,6 +45,7 @@ async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDe ...services, }, settings: mock.remote.settings, + session: { initializeDefaultModel: vi.fn(async () => ({ ok: true, value: undefined })) }, }) // The fixed Host facts the settings provider reads its persistence from. remote.$host = { home: undefined, isLoopback } @@ -107,7 +108,7 @@ describe('ui-settings-models apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'remote.session', 'settingsScope', 'settingsSchema', ]) }) diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index e9cec03e60..88e43e599f 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -12,7 +12,7 @@ import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' -import { pathOps } from '../src/client/ProviderEditor.tsx' +import { ProviderEditor, pathOps } from '../src/client/ProviderEditor.tsx' import { DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' @@ -22,7 +22,7 @@ import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import { createModelsOperations } from '../src/client/operations.ts' import type { ModelsOperations } from '../src/client/operations.ts' import type { ProviderRow } from '../src/client/store.ts' -import { en } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' afterEach(cleanup) @@ -218,7 +218,9 @@ const contexts = new WeakMap() function ctxWith(face: object): PageContext { const existing = contexts.get(face) if (existing !== undefined) return existing - const ctx = { remote: face } as unknown as PageContext + const ctx = { remote: { ...face, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + } } as unknown as PageContext contexts.set(face, ctx) return ctx } @@ -1620,3 +1622,18 @@ describe('apiKeyFailure', () => { expect(apiKeyFailure('"a')).toBeUndefined() }) }) + +it.each([en, zh])('edits the account model catalog without credential or endpoint fields', async (copy) => { + const scripted = scriptedFace({}) + const ops = operationsWith(scripted.face) + const describe = vi.spyOn(ops, 'describeCredential') + render( copy[key]} readOnly={false} onClose={() => {}} />) + expect(screen.queryByLabelText(copy.keyInput)).toBeNull() + expect(screen.queryByLabelText(copy.baseUrl)).toBeNull() + expect(describe).not.toHaveBeenCalled() + expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() + await expect(`${document.body.textContent}\n`) + .toMatchFileSnapshot(`./expected/deepseek-account-${copy === en ? 'en' : 'zh'}.txt`) +}) diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt new file mode 100644 index 0000000000..c9841889a6 --- /dev/null +++ b/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt @@ -0,0 +1 @@ +DeepSeek Accountdeepseek-accountModelsUsing the adapter defaultsAdd modelCancelApply diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt new file mode 100644 index 0000000000..e11c2e1969 --- /dev/null +++ b/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt @@ -0,0 +1 @@ +DeepSeek 账号deepseek-account模型目录正在使用适配器默认模型添加模型取消保存 diff --git a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx index 60e7d79165..f5c50b3056 100644 --- a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx @@ -12,6 +12,7 @@ function operations(discoverModels: ModelsOperations['discoverModels']): ModelsO return { discoverModels, describeCredential: vi.fn(), + initializeModel: vi.fn(), storeCredential: vi.fn(), removeCredential: vi.fn(), writeSettings: vi.fn(), diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 89bc6b2ca7..d19873f3b2 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -136,7 +136,7 @@ function harness(options: { }, } // The page plugin's context, scripted down to the namespaces it reaches. - const ctx = { remote: face } as never + const ctx = { remote: { ...face, session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) } } } as never const operations = createModelsOperations(ctx) const controller = new ModelsSettingsStore(ctx, settingsSchema, new SettingsDescribeMirror(ctx)) const openSection = vi.fn() diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 57c61d702b..ae71cf0557 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -153,7 +153,9 @@ const contexts = new WeakMap() function ctxWith(face: object): PageContext { const existing = contexts.get(face) if (existing !== undefined) return existing - const ctx = { remote: face } as unknown as PageContext + const ctx = { remote: { ...face, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + } } as unknown as PageContext contexts.set(face, ctx) return ctx } diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 0ce5a995a1..d181edc12e 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -4,7 +4,7 @@ import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' -import { joinProviderDirectory, ModelsSettingsStore } from '../src/client/store.ts' +import { joinProviderDirectory, ModelsSettingsStore, providerUsable } from '../src/client/store.ts' it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => { expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{ @@ -64,6 +64,7 @@ const NAMESPACES = [ ] function api(overrides: { + accountAvailable?: boolean providers?: () => Promise> describeSettings?: () => Promise> describeCredentials?: (refs: readonly string[]) => Promise>> @@ -88,6 +89,8 @@ function api(overrides: { : remoteFail(response.result.error.message) } const face = { + session: { modelCatalog: async () => remoteOk({ groups: overrides.accountAvailable + ? [{ id: 'deepseek-account', models: [{ id: 'deepseek-flash' }] }] : [] }) }, llm: { listProviders: () => mapProviderBatch(rows => rows .filter(row => row.active) @@ -329,3 +332,16 @@ describe('edge joins', () => { expect(store.store.getSnapshot().rows).toHaveLength(4) }) }) + + +it.each([false, true])('uses account availability without asking for an API key: %s', async (accountAvailable) => { + const { ctx, mirror, seenRefs } = api({ accountAvailable, providers: async () => ok({ providers: [{ + provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [], active: true, + }] }) }) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) + await store.load() + const row = store.store.getSnapshot().rows[0]! + expect(row).toMatchObject({ accountAvailable, apiKeyEnv: undefined, credential: undefined }) + expect(providerUsable(row)).toBe(accountAvailable) + expect(seenRefs).toEqual([]) +}) diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index d32bdafa59..421bdb1e70 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/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/core/agent-default-model/README.md -README.md: df8b9c37ea58f4d35d2f9bda1f1e7001c8b2de59 -README.zh.md: d9d78452de9e0fa2a20bcc0bd9d16c41c69e1729 +README.md: 97130a007d91f936450e3593a4ea823ca90d4ae2 +README.zh.md: 2cb9d0b837c55c976c712f286f89a65eb0afab3a diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index df8b9c37ea..97130a007d 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -54,7 +54,7 @@ const selection = ctx.agentDefaultModel.currentSelection() await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'high' }) ``` -Without a settings provider, `saveSelection()` is a no-op and the composition entry remains current. The service does not validate catalog membership: a provider route may serve an unadvertised model, and the consumer that opens a model request owns availability diagnostics. +Without a settings provider, `saveSelection()` retains the choice in memory for later agents in this process. `initializeSelection()` saves the first setup choice only when no user choice exists; an unavailable saved choice remains intact. The service does not validate catalog membership: a provider route may serve an unadvertised model, and the consumer that opens a model request owns availability diagnostics. ----- @@ -79,7 +79,7 @@ The service is a composition entry with a settings-backed source. The plugin con ### Behavior notes -Both public methods are thin reads and writes over that source: `currentSelection()` returns a fresh detached object so a caller can hold it without aliasing service state, and `saveSelection()` writes the whole selection through `ctx.settings` when present. +Reads return detached values and writes are serialized: `currentSelection()` returns a fresh detached object so a caller can hold it without aliasing service state, and `saveSelection()` writes the whole selection through `ctx.settings` when present. @@ -114,7 +114,7 @@ Changing the default affects only agents that subsequently resolve from it. An e These limits define the service's scope. They are current package constraints, not a task backlog. - **One process-wide default** — the service owns a single default; per-session model selection remains the entry point's responsibility. -- **No retention without a settings provider** — `saveSelection()` cannot keep a selection for a later agent when no settings provider is mounted. +- **No persistence without a settings provider** — in-memory selections last only for this process. ### Dev Note diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index d9d78452de..2cb9d0b837 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -54,7 +54,7 @@ const selection = ctx.agentDefaultModel.currentSelection() await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'high' }) ``` -未挂载设置提供方时,`saveSelection()` 不执行任何操作,组合配置项仍为当前值。该服务不校验目录成员关系:提供方路由可以服务未在目录中公布的模型;发起模型请求的消费方负责可用性诊断。 +未挂载设置提供方时,`saveSelection()` 在内存中保留选择,供本进程后续 agent 使用。`initializeSelection()` 仅在用户尚未选择时保存首次配置的模型;已保存的选择即使不可用也保持不变。该服务不校验目录成员关系:提供方路由可以服务未在目录中公布的模型;发起模型请求的消费方负责可用性诊断。 ----- @@ -79,7 +79,7 @@ await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'h ### 行为说明 -两个公开方法都只是对该数据源进行简单读写:`currentSelection()` 返回一个全新、独立的对象,因此调用方可以持有它,而不会与服务状态共享引用;`saveSelection()` 在存在 `ctx.settings` 时写入完整选择。 +读取返回独立值,写入按序执行:`currentSelection()` 返回一个全新、独立的对象,因此调用方可以持有它,而不会与服务状态共享引用;`saveSelection()` 在存在 `ctx.settings` 时写入完整选择。 @@ -114,7 +114,7 @@ await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'h 这些限制界定该服务的范围。它们是当前包约束,不是任务积压。 - **单一的进程级默认值**——该服务只拥有一个默认值;按会话的模型选择仍由入口负责。 -- **没有设置提供方时无法保留**——未挂载设置提供方时,`saveSelection()` 无法为后续 agent 保留选择。 +- **没有设置提供方时无法持久化**——内存中的选择仅在本进程内保留。 ### 开发备注 diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 82e4e0da6d..a2c804836c 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -68,6 +68,8 @@ export class AgentDefaultModelConfig extends Service { }) private source: () => AgentDefaultModelSettings + private saved: ModelSelection | undefined + private writes: Promise = Promise.resolve() constructor(ctx: Context, config: Config) { super(ctx, 'agentDefaultModel') @@ -88,22 +90,45 @@ export class AgentDefaultModelConfig extends Service { * @returns a detached provider, model, and optional reasoning selection. */ currentSelection(): ModelSelection { - return selection(this.source()) + return this.ctx.get('settings') === undefined && this.saved !== undefined + ? { ...this.saved } : selection(this.source()) } /** * Save the complete default model selection. A deployment without a settings - * provider keeps its composition entry. + * provider retains the selection for this process. * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise { - await this.ctx.get('settings')?.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, { - provider: next.provider, - model: next.model, - ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, - }) + return this.writeSelection(next, false) } + + /** + * Store the initial credential setup choice only before a user selection exists. + * @param next - available model belonging to the provider the user initialized. + * @returns after the initial selection is saved, or immediately if already selected. + */ + async initializeSelection(next: ModelSelection): Promise { + return this.writeSelection(next, true) + } + + private writeSelection(next: ModelSelection, initialize: boolean): Promise { + const operation = this.writes.then(async () => { + const user = this.ctx.get('settings')?.describe().find(entry => entry.ns === AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)?.user + if (initialize && (this.saved !== undefined + || (typeof user === 'object' && user !== null && ('provider' in user || 'model' in user)))) return + await this.ctx.get('settings')?.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, { + provider: next.provider, + model: next.model, + ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, + }) + this.saved = { ...next } + }) + this.writes = operation.catch(() => { /* A refused write does not block the next user choice. */ }) + return operation + } + } export default AgentDefaultModelConfig diff --git a/packages/core/agent-default-model/tests/agent-default-model.spec.ts b/packages/core/agent-default-model/tests/agent-default-model.spec.ts index b16091d809..be76848e67 100644 --- a/packages/core/agent-default-model/tests/agent-default-model.spec.ts +++ b/packages/core/agent-default-model/tests/agent-default-model.spec.ts @@ -77,22 +77,33 @@ describe('AgentDefaultModelConfig', () => { await bench.ctx.fiber.dispose() }) - it('falls back to the composition entry when the settings provider detaches', async () => { + it('retains the saved selection when the settings provider detaches', async () => { const bench = await boot() await bench.defaultModel.saveSelection({ provider: 'acme-gateway', model: 'acme-large' }) expect(bench.defaultModel.currentSelection().provider).toBe('acme-gateway') await bench.settingsFiber.dispose() - expect(bench.defaultModel.currentSelection()).toEqual({ - provider: 'deepseek-official', model: 'deepseek-v4-flash', - }) + expect(bench.defaultModel.currentSelection()).toEqual({ provider: 'acme-gateway', model: 'acme-large' }) await bench.ctx.fiber.dispose() }) - it('keeps the composition entry when no settings provider is mounted', async () => { + it('retains a user selection when no settings provider is mounted', async () => { const ctx = new Context() await ctx.plugin(AgentDefaultModelConfig, { provider: 'p', model: 'm' }) await ctx.agentDefaultModel.saveSelection({ provider: 'other', model: 'other' }) - expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'p', model: 'm' }) + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'other', model: 'other' }) await ctx.fiber.dispose() }) }) + +it('initializes once and retains an unavailable user selection instead of replacing it', async () => { + const bench = await boot() + try { + await bench.defaultModel.initializeSelection({ provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + expect(bench.defaultModel.currentSelection().provider).toBe('deepseek-account') + await bench.defaultModel.initializeSelection({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }) + expect(bench.defaultModel.currentSelection().provider).toBe('deepseek-account') + await bench.defaultModel.saveSelection({ provider: 'deleted-provider', model: 'removed-model' }) + await bench.defaultModel.initializeSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + expect(bench.defaultModel.currentSelection()).toEqual({ provider: 'deleted-provider', model: 'removed-model' }) + } finally { await bench.ctx.fiber.dispose() } +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9e8c680d18..5706f2a77d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -94,10 +94,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async saveSelection(next: ModelSelection): Promise', - description: 'Save the complete default model selection. A deployment without a settings provider keeps its composition entry.', + description: 'Save the complete default model selection. A deployment without a settings provider retains the selection for this process.', parameters: [{ name: 'next', description: 'resolved selection accepted by an entry point.' }], returns: 'fulfillment after the optional settings write settles.', }, + { + signature: 'async initializeSelection(next: ModelSelection): Promise', + description: 'Store the initial credential setup choice only before a user selection exists.', + parameters: [{ name: 'next', description: 'available model belonging to the provider the user initialized.' }], + returns: 'after the initial selection is saved, or immediately if already selected.', + }, ], }, { @@ -1780,6 +1786,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'Session identity and requested model selection.' }], returns: 'the normalized selection installed for the Session.', }, + { + signature: '@Remote async initializeDefaultModel(provider: string): Promise', + description: 'Initialize the default after the user first configures a provider credential.', + parameters: [{ name: 'provider', description: 'the provider whose credential was configured.' }], + returns: 'after saving the initial available model; existing user choices are retained.', + }, { signature: '@Remote(\'modelCatalog\') modelCatalog(): Promise', description: 'Describe every currently routable model for Host-generation selectors.', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 11c68612d8..fcc298d024 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: abd29fbdc49a100fa3e5a92ad4db05fc487ef456 -README.zh.md: d955dc40190a2b00d9034859c99c004ba06a4fa3 +README.md: 7ded16b5e1d8c87b924aeb02c8eda0a3033bd3ee +README.zh.md: de612f7b2153eab35e8a00db71aeb1bb92824eb1 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index abd29fbdc4..7ded16b5e1 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -117,6 +117,8 @@ Successful Files responses must contain valid JSON. JSON decoding failures from Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request with no key anywhere fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key. +Each route advertises models only while its own credential resolves. Missing API keys and missing or ineligible account grants yield an empty catalog; other credential errors remain visible. This check does not probe the inference endpoint. Both configurable provider entries share the same model catalog. + ----- diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index d955dc4019..de612f7b21 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -117,6 +117,8 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。任何位置都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 +每条路由仅在其自身凭据可解析时公布模型。缺少 API Key 或账号授权缺失、不适用时返回空目录;其他凭据错误仍会暴露。此检查不会探测推理端点。两个可配置提供方条目共用同一份模型目录。 + ----- diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index c25125987e..568b3190e6 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,6 +1,6 @@ /** Select a DeepSeek wire implementation from one validated configuration generation. */ import { assertNever } from '@deepseek-ai/dsh-util-values' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' import type { DeepSeekAdapterOptions } from './common/types.ts' import { ChatCompletionsAdapter } from './protocols/chat-completions/adapter.ts' @@ -41,9 +41,16 @@ export class DeepSeekAdapter extends LlmAdapter { } } - override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek (Account)' : 'DeepSeek (API Key)' } } + override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek (API Key)' } } override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } - override listModels(provider: string) { return this.implementation().listModels(provider) } + override async listModels(provider: string) { + try { await this.dependencies.resolveApiKey(this.dependencies.options()) } + catch (error) { + if (error instanceof LlmError && ['MISSING_CREDENTIAL', 'ACCOUNT_SIGN_IN_REQUIRED'].includes(error.code)) return [] + throw error + } + return this.implementation().listModels(provider) + } override resolveModel(provider: string, model: string, signal?: AbortSignal) { return this.implementation().resolveModel(provider, model, signal) } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index c740a9e449..f0c262c722 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -134,6 +134,7 @@ export function apply(ctx: Context, config: Config): void { }) ctx.llm.registerConfigurableProviders([ { provider: PROVIDER, displayName: 'DeepSeek (API Key)', settingsNs: NS, settingsPath: [] }, + { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts index 64b050fd02..95ffb361ca 100644 --- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -214,3 +214,20 @@ it('cancels an account child without stopping its API-key parent', async () => { parent.cancel({ kind: 'user' }) await parent.whenIdle() }) + +it('advertises account and API-key models only while their own credential is available', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = await harness() + let token: string | undefined + ctx.provide('deepseekAccount', { resolveToken: async (url: string) => url === 'https://api.deepseek.com' ? token : undefined } as DeepSeekAccount) + expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).toEqual([]) + token = 'fixture-account-token' + expect(await ctx.llm.listModels('deepseek-account')).not.toHaveLength(0) + expect(await ctx.llm.listModels('deepseek-official')).toEqual([]) + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-key') + expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) + token = undefined + expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) +}) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 2c95e5db00..89d33d64b4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1697,13 +1697,13 @@ describe('plugin registration and config', () => { protocol: 'chat-completions', baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', displayName: 'DeepSeek (API Key)', settingsNs: 'llm-deepseek', settingsPath: [], - }]) + }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [] }]) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) expect(ctx.llm.listConfigurableProviders()).toEqual([]) @@ -1730,10 +1730,11 @@ describe('plugin registration and config', () => { }) it('owns the deepseek provider and advertises the default models', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -1861,6 +1862,7 @@ describe('plugin registration and config', () => { }) it('uses the default model catalog when apply is called directly', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const ctx = new Context() await ctx.plugin(LlmRuntime) LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) @@ -1893,6 +1895,7 @@ describe('plugin registration and config', () => { }) it('advertises configured models without restricting arbitrary request ids', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { @@ -2186,18 +2189,17 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) - it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { + it('loads keyless, hides unavailable models, and fails the request actionably', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - // First-boot onboarding: the route registers so models stay discoverable; - // only the request itself needs a key. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) + // The registered provider remains configurable before its credential exists. + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(0) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) // The guidance leads with the managed credential store. @@ -2277,7 +2279,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 175e1ab36e..dae9ded55a 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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-pi-ai/README.md -README.md: 4b3297ab96c2c691f9bbf849d8e49cb10a126a6a -README.zh.md: b33381468891fa81b199cca40f22a59183f5076a +README.md: ad39c0d53bc41b6c0da4d046a5d0cadd6ce6b3dc +README.zh.md: 082e8e4da8fd9b9948e9284cf9441f1e7d3e9c81 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 4b3297ab96..ad39c0d53b 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -120,6 +120,8 @@ Settings writes strictly validate each new or changed provider after merging its Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently. +Model discovery resolves each route’s credential configuration. A missing required credential yields an empty catalog; other resolution errors propagate. Routes with native authentication or no required key retain their configured catalog. This check does not probe the inference endpoint. + ----- diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index b333814688..082e8e4da8 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -120,6 +120,8 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或 只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。 +模型发现会解析各路由的凭据配置。缺少必需凭据时返回空目录;其他解析错误继续抛出。使用原生认证或无需密钥的路由保留其配置的目录。此检查不会探测推理端点。 + ----- diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index de9f3e0e42..3370e1ffcc 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -274,9 +274,14 @@ export class PiAiAdapter extends LlmAdapter { } override listModels(provider: string): Promise { - return Promise.resolve().then(() => { + return Promise.resolve().then(async () => { const snapshot = this.current() - this.profileOf(snapshot, provider) + const profile = this.profileOf(snapshot, provider) + try { await this.config.resolveApiKey(provider, profile) } + catch (error) { + if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return [] + throw error + } return snapshot.models.getModels(provider).map(model => ({ provider, id: model.id, diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 85534d6773..ef4e3aa4be 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -1143,7 +1143,7 @@ describe('resolution snapshots', () => { profiles: () => current, // Credential resolution is the real await inside a stream call, and the // window a configuration change has to land in. - resolveApiKey: async () => { await held; return 'k' }, + resolveApiKey: async (provider) => { if (provider === 'deepseek') await held; return 'k' }, auth: memoryAuth(), }) From 325e28bfa0e310fd36dd8ecb40ee8f565cc1fb66 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:42:43 +0800 Subject: [PATCH 03/30] fix(ui-model-selection,llm-deepseek): prioritize account models and simplify provider label --- .../client/ui-model-selection/README.i18n.yaml | 4 ++-- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../src/client/ModelSelect.tsx | 8 +++++--- .../tests/expected/account-first.txt | 8 ++++++++ .../tests/model-select.client.spec.tsx | 18 ++++++++++++++++++ packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-deepseek/src/index.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 12 ++++++------ 9 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 packages/client/ui-model-selection/tests/expected/account-first.txt diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 9ba09cab47..2b7317e90e 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/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/client/ui-model-selection/README.md -README.md: 7afc8f158509e18e90236d6302ab51125fa7a891 -README.zh.md: c14ad7ab05af24c35727d42e6d843cbdaf20f332 +README.md: 9b73298b776d2f3d869a5da88db8d17e14f0f3a9 +README.zh.md: 4dbd077a30cbbaaefd9fabcbf1cc4661367a3069 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 7afc8f1585..9b73298b77 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -31,7 +31,7 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp ### Model and effort -Models stay grouped by provider. The composer menu shows model and effort names only. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first; other providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. ### Unroutable sessions diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index c14ad7ab05..4dbd077a30 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -31,7 +31,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -模型按提供方分组。composer 菜单只显示模型与推理强度名称。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号置顶,其他提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 ### 不可路由的会话 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index b9c8993563..5e33fbe526 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -75,7 +75,9 @@ export function ModelSelect( const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) const id = useId() - const choices = useMemo(() => state.groups.flatMap(group => + const groups = useMemo(() => state.groups.toSorted((left, right) => + Number(right.id === 'deepseek-account') - Number(left.id === 'deepseek-account')), [state.groups]) + const choices = useMemo(() => groups.flatMap(group => group.models.map(model => ({ group, model, @@ -86,7 +88,7 @@ export function ModelSelect( ? {} : { reasoningEffort: model.reasoning.defaultEffort }, } satisfies ModelSelection, - }))), [state.groups]) + }))), [groups]) const selectedIndex = state.current === null ? -1 : choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model) @@ -409,7 +411,7 @@ export function ModelSelect(
))}
- {state.groups.map((group) => { + {groups.map((group) => { const headingId = `${id}-${group.id}` return (
diff --git a/packages/client/ui-model-selection/tests/expected/account-first.txt b/packages/client/ui-model-selection/tests/expected/account-first.txt new file mode 100644 index 0000000000..304c901c9f --- /dev/null +++ b/packages/client/ui-model-selection/tests/expected/account-first.txt @@ -0,0 +1,8 @@ +deepseek-account-1 +deepseek-account-2 +custom-1 +custom-2 +deepseek-official-1 +deepseek-official-2 +another-1 +another-2 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 3ac3613aac..e71062bbdc 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -455,3 +455,21 @@ it('shows the unselected model control without a stale model or effort', async ( await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') expect(trigger.textContent).not.toContain('High') }) + + +it('places account models first while preserving other provider and model order', async () => { + const groups = ['custom', 'deepseek-official', 'deepseek-account', 'another'].map(id => ({ + id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })), + })) + const directory = createSnapshotStore(state({ current: null, groups })) + render() + fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) + expect(names).toEqual([ + 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', + 'deepseek-official-1', 'deepseek-official-2', 'another-1', 'another-2', + ]) + expect(groups.map(group => group.id)).toEqual(['custom', 'deepseek-official', 'deepseek-account', 'another']) + await expect(`${names.join('\n')}\n`).toMatchFileSnapshot('./expected/account-first.txt') +}) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 568b3190e6..95add7b318 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -41,7 +41,7 @@ export class DeepSeekAdapter extends LlmAdapter { } } - override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek (API Key)' } } + override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek' } } override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } override async listModels(provider: string) { try { await this.dependencies.resolveApiKey(this.dependencies.options()) } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index f0c262c722..bd159998e3 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -133,7 +133,7 @@ export function apply(ctx: Context, config: Config): void { }, }) ctx.llm.registerConfigurableProviders([ - { provider: PROVIDER, displayName: 'DeepSeek (API Key)', settingsNs: NS, settingsPath: [] }, + { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 89d33d64b4..fb8c01a25a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1697,10 +1697,10 @@ describe('plugin registration and config', () => { protocol: 'chat-completions', baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', - displayName: 'DeepSeek (API Key)', + displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [] }]) @@ -1734,7 +1734,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -2189,7 +2189,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) it('loads keyless, hides unavailable models, and fails the request actionably', async () => { @@ -2198,7 +2198,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) // The registered provider remains configurable before its credential exists. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(0) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) @@ -2279,7 +2279,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { From 7dc86578d8870fdaea99361670f6d1448c43a86f Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:50:14 +0800 Subject: [PATCH 04/30] fix(ui-settings-account,ui-model-selection): align with updated primitives and catalog checks --- .../tests/expected/default-model/unselected.expected.md | 7 ++----- .../ui-model-selection/tests/browser-plugin.client.spec.ts | 7 +++---- .../ui-settings-account/src/client/SignOutDialog.tsx | 4 ++-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/expected/default-model/unselected.expected.md b/apps/web/tests/expected/default-model/unselected.expected.md index c4fb007e9c..6228bea41c 100644 --- a/apps/web/tests/expected/default-model/unselected.expected.md +++ b/apps/web/tests/expected/default-model/unselected.expected.md @@ -1,8 +1,5 @@ - textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" [disabled] -- button "添加文件或调用指令" [disabled]: - - img +- button "添加文件或调用指令" [disabled] - button "访问模式,当前:工作区内修改" [disabled]: 工作区内修改 -- button "请选择模型": - - text: 请选择模型 - - img +- button "请选择模型" - button "发送消息" [disabled] diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 67484a5d95..4fa513e9ba 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -71,8 +71,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { let selected = defaultSelection const calls = { models: 0, select: 0 } const projections = new Map>() - // Whether the Host reports an adapter for the current route; the composer - // block follows this, never catalog membership. + // Whether the Host advertises available models for the current route. let routable = true let selectionFailure: RemoteError<'session/writer-held'> | undefined const sessionRemote = { @@ -382,14 +381,14 @@ describe('ui-model-selection dual entry', () => { await oldDirectory.load() const replacement = b.mint('s1') replacement.projection.set({ lastUsed: null, - next: { provider: routable ? 'deepseek-official' : 'missing', model: 'replacement' } }) + next: { provider: routable ? 'deepseek-official' : 'missing', model: 'deepseek-v4-flash' } }) const directory = b.ctx.modelDirectories.directoryFor(sid('s1')) try { expect(directory.store.getSnapshot().routable).toBe(routable) const expected = b.blockOf('s1') await Promise.resolve().then(() => { first.projection.set({ lastUsed: null, - next: { provider: routable ? 'missing' : 'deepseek-official', model: 'late-old' } }) + next: { provider: routable ? 'missing' : 'deepseek-official', model: 'deepseek-v4-pro' } }) }) expect(oldDirectory.store.getSnapshot().routable).toBe(!routable) expect(b.blockOf('s1')).toBe(expected) diff --git a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx index c0679a0b2d..f9b59e8ea8 100644 --- a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx @@ -1,6 +1,6 @@ /** Account sign-out confirmation with the current task impact. */ import { useState } from 'react' -import { Button, IconCloseOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconCloseOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { AccountKey } from './locales.ts' import css from './SignInDialog.module.css' @@ -26,7 +26,7 @@ export function SignOutDialog({ running, signOut, close, t }: {

{t('signOut')}

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

From 9cf12959f22efe0e43ac097f69a503077a265c12 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:05:58 +0800 Subject: [PATCH 05/30] fix(ui-model-selection): retain effort and open unselected models directly --- apps/web/tests/default-model.e2e.ts | 3 ++- .../ui-model-selection/README.i18n.yaml | 4 ++-- packages/client/ui-model-selection/README.md | 2 ++ .../client/ui-model-selection/README.zh.md | 2 ++ .../src/client/ModelSelect.module.css | 6 ++--- .../src/client/ModelSelect.tsx | 9 ++++---- .../ui-model-selection/src/client/catalog.ts | 19 ++++++++++++++- .../src/client/directory.ts | 15 +++++++++--- .../tests/browser-plugin.client.spec.ts | 23 +++++++++++++++++++ .../tests/expected/unselected-model.txt | 2 +- .../tests/model-select.client.spec.tsx | 13 +++++++---- 11 files changed, 79 insertions(+), 19 deletions(-) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index f9fd8da7ea..40b3911071 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -104,6 +104,7 @@ describe('web e2e: the composer model switch is the default for later sessions', const trigger = page.getByRole('button', { name: /^选择模型/ }) await trigger.waitFor({ timeout: 15_000 }) + expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400') await trigger.click() await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio', { name: 'Acme Large' }).click() @@ -143,6 +144,7 @@ describe('web e2e: the composer model switch is the default for later sessions', await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false) const unset = page.getByRole('button', { name: '请选择模型', exact: true }) await unset.waitFor() + expect(await unset.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400') expect(await page.getByRole('button', { name: '发送消息', exact: true }).isEnabled()).toBe(false) const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode()) @@ -161,7 +163,6 @@ describe('web e2e: the composer model switch is the default for later sessions', const seat = page.getByRole('button', { name: '请选择模型', exact: true }) expect(await seat.isEnabled()).toBe(true) await seat.click() - await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio').first().click() await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 2b7317e90e..db4fe73951 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/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/client/ui-model-selection/README.md -README.md: 9b73298b776d2f3d869a5da88db8d17e14f0f3a9 -README.zh.md: 4dbd077a30cbbaaefd9fabcbf1cc4661367a3069 +README.md: 3c73a42b34ad9075eaaab9e256767b7250ea0513 +README.zh.md: 87cccdeacde3183427a1ff08e1d1288e5cf170be diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 9b73298b77..3c73a42b34 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -27,6 +27,8 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses DeepSeek account and API-key routes appear as separate provider groups, each exposing the same configured model catalog. +The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it. + Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row clears the effective selection and the trigger prompts `Select model`, without changing the stored choice. ### Model and effort diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 4dbd077a30..87cccdeacd 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -27,6 +27,8 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示相同的已配置模型目录。 +未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。 + 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,有效选择置空,触发器提示“请选择模型”,已保存的选择不变。 ### 模型与推理强度 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 799c10776c..3f42221fca 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -3,7 +3,7 @@ min-width: 0; } -/* Figma 313:14108 ToggleButton: 13/20 medium secondary label, 4px gap, +/* Figma 313:14108 ToggleButton: 13/20 regular secondary label, 4px gap, 12px caption chevron; 28px chip height matches the sibling Plan / Read-only selects in the same tool row. */ .trigger { @@ -25,7 +25,7 @@ color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; - font-weight: 500; + font-weight: 400; cursor: pointer; } @@ -49,7 +49,7 @@ white-space: nowrap; } -/* Effort value beside the model name (mock's 'High': same 13/20/500, caption +/* Effort value beside the model name (mock's 'High': same 13/20/400, caption tone). Adaptive priority: the huge shrink factor makes the effort absorb all deficit first — it ellipsizes away to nothing before the model name loses a single pixel, and the chevron stays snug throughout. */ diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 5e33fbe526..2223cd3c21 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -96,7 +96,7 @@ export function ModelSelect( const reasoning = currentChoice?.model.reasoning const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort const effortLabel = reasoning === undefined - ? undefined + ? state.retainedEffort : effectiveEffort === undefined ? t('effort.providerDefault') : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort @@ -192,7 +192,8 @@ export function ModelSelect( if (!available) return null const show = (): void => { - setPane('root') + if (state.current === null) paneFocus.current = 'drill' + setPane(state.current === null ? 'model' : 'root') setOpen(true) reload() } @@ -231,7 +232,7 @@ export function ModelSelect( if (event.key === 'Escape' && open) { event.preventDefault() // Escape backs out of a drilled pane first, then closes. - if (pane !== 'root') back(pane) + if (pane !== 'root' && state.current !== null) back(pane) else close(true) return } @@ -242,7 +243,7 @@ export function ModelSelect( if (event.key === 'Tab') { if (event.shiftKey) { event.preventDefault() - if (pane !== 'root') back(pane) + if (pane !== 'root' && state.current !== null) back(pane) else close(true) return } diff --git a/packages/client/ui-model-selection/src/client/catalog.ts b/packages/client/ui-model-selection/src/client/catalog.ts index d5073d2ab0..cbc92642e7 100644 --- a/packages/client/ui-model-selection/src/client/catalog.ts +++ b/packages/client/ui-model-selection/src/client/catalog.ts @@ -1,7 +1,7 @@ /** One Host-generation model catalog shared by every Session selector. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' +import type { ModelCatalog, ModelSelection, ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' /** Observable lifecycle of the shared model catalog. */ @@ -20,6 +20,17 @@ export class ModelCatalogDirectory { error: null, }) + private readonly reasoning = new Map() + + /** + * Read the last advertised reasoning metadata, including unavailable models. + * @param selection - provider and model whose effort is displayed. + * @returns reasoning metadata observed during this Host generation. + */ + reasoningFor(selection: ModelSelection): ModelProviderGroup['models'][number]['reasoning'] { + return this.reasoning.get(JSON.stringify([selection.provider, selection.model])) + } + private generation = 0 private inflight: Promise | undefined @@ -47,6 +58,11 @@ export class ModelCatalogDirectory { throw new Error(`${response.error.code}: ${response.error.message}`) } if (generation === this.generation) { + for (const group of response.value.groups) { + for (const model of group.models) { + this.reasoning.set(JSON.stringify([group.id, model.id]), model.reasoning) + } + } this.store.set({ value: response.value, status: 'ready', error: null }) } return response.value @@ -84,6 +100,7 @@ export class ModelCatalogDirectory { /** Clear Host-specific values and load the replacement Host generation. */ resetGeneration(): void { + this.reasoning.clear() this.invalidate(true) void this.load().catch(() => { /* the selector exposes the shared error */ }) } diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 8e03bcf206..162ad5db7d 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -17,6 +17,8 @@ import type { ModelCatalogDirectory } from './catalog.ts' export interface ModelDirectoryState { /** Available durable selection, or the available default for an unselected Session. */ current: ModelSelection | null + /** Saved effort caption retained when the selected model is unavailable. */ + retainedEffort?: string /** Whether the current selection is present in the available catalog; null while unresolved. */ routable: boolean | null /** Successfully loaded provider groups (last good load). */ @@ -136,9 +138,15 @@ export class ModelDirectory { if (this.disposed) return const catalog = this.catalog.store.getSnapshot() const projected = modelSelectionProjection(this.projected.getSnapshot()) + const intended = projected?.next ?? catalog.value?.default + const reasoning = intended === undefined ? undefined : this.catalog.reasoningFor(intended) + const effort = intended?.reasoningEffort ?? reasoning?.defaultEffort + const retainedEffort = effort === undefined ? undefined + : reasoning?.efforts.find(level => level.id === effort)?.name ?? effort if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { this.store.set({ current: null, + ...retainedEffort === undefined ? {} : { retainedEffort }, routable: null, groups: [], failures: [], @@ -147,11 +155,12 @@ export class ModelDirectory { }) return } - const intended = projected.next ?? catalog.value.default - const current = catalog.value.groups.some(group => group.id === intended.provider - && group.models.some(model => model.id === intended.model)) ? intended : null + const selection = projected.next ?? catalog.value.default + const current = catalog.value.groups.some(group => group.id === selection.provider + && group.models.some(model => model.id === selection.model)) ? selection : null this.store.set({ current, + ...retainedEffort === undefined ? {} : { retainedEffort }, routable: current !== null, groups: catalog.value.groups, failures: catalog.value.failures, diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 4fa513e9ba..af7d3dee20 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -414,6 +414,29 @@ describe('ui-model-selection dual entry', () => { } }) + it('retains saved effort for existing and new sessions after credentials disappear', async () => { + const b = await bench() + try { + b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }) + b.mint('existing') + const existing = b.ctx.modelDirectories.directoryFor(sid('existing')) + await existing.load() + b.setProjected(sid('existing'), { lastUsed: null, + next: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' } }) + b.setRoutable(false) + b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) + expect(existing.store.getSnapshot().retainedEffort).toBe('High') + await vi.waitFor(() => { + expect(existing.store.getSnapshot()).toMatchObject({ current: null, routable: false, retainedEffort: 'High' }) + }) + b.mint('new') + const fresh = b.ctx.modelDirectories.directoryFor(sid('new')) + expect(fresh.store.getSnapshot()).toMatchObject({ current: null, routable: false, retainedEffort: 'Max' }) + } finally { + await b.ctx.fiber.dispose() + } + }) + it('blocks the composer when current catalog models disappear', async () => { const b = await bench() b.mint('s1') diff --git a/packages/client/ui-model-selection/tests/expected/unselected-model.txt b/packages/client/ui-model-selection/tests/expected/unselected-model.txt index d6d4beb3b1..2e251b01d4 100644 --- a/packages/client/ui-model-selection/tests/expected/unselected-model.txt +++ b/packages/client/ui-model-selection/tests/expected/unselected-model.txt @@ -1 +1 @@ -请选择模型 +请选择模型High diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index e71062bbdc..2d8df4fad0 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -447,13 +447,19 @@ describe('ModelSelect keyboard walk', () => { }) }) -it('shows the unselected model control without a stale model or effort', async () => { - const directory = createSnapshotStore(state({ current: null, routable: false })) +it('shows the unselected model control with the inherited effort', async () => { + const directory = createSnapshotStore(state({ current: null, routable: false, retainedEffort: 'High' })) render() const trigger = screen.getByRole('button', { name: '请选择模型' }) expect(trigger.hasAttribute('disabled')).toBe(false) await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') - expect(trigger.textContent).not.toContain('High') + expect(trigger.textContent).toContain('High') + fireEvent.click(trigger) + expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull() + const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) + expect(document.activeElement).toBe(model) + fireEvent.keyDown(model, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() }) @@ -464,7 +470,6 @@ it('places account models first while preserving other provider and model order' const directory = createSnapshotStore(state({ current: null, groups })) render() fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) expect(names).toEqual([ 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', From 0d2fbaad67707f95b3281a1e39340c606283246a Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:27:35 +0800 Subject: [PATCH 06/30] fix(session-controller,ui-model-selection,ui-settings): handle catalog and account setup failures --- apps/web/tests/default-model.e2e.ts | 6 +-- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 ++-- docs/event-producer-consumer.zh.md | 10 ++-- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 4 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 8 ++-- docs/subsystems/llm-streaming.zh.md | 8 ++-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/catalog.ts | 17 +++++-- packages/api/session-controller/src/index.ts | 3 +- packages/api/session-controller/src/types.ts | 1 + .../tests/session-models.host.spec.ts | 46 +++++++++++++++++- .../client/skeleton/ConversationContent.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 7 ++- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 4 +- .../client/ui-model-selection/README.zh.md | 4 +- .../src/client/ModelSelect.tsx | 7 +-- .../src/client/directory.ts | 8 ++-- .../ui-model-selection/src/client/index.ts | 5 +- .../ui-model-selection/src/client/locales.ts | 2 + .../tests/browser-plugin.client.spec.ts | 48 ++++++++++++++++++- .../tests/expected/account-first.txt | 4 +- .../tests/model-select.client.spec.tsx | 21 ++++++-- .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 4 +- .../client/ui-settings-account/README.zh.md | 4 +- .../src/client/AccountMenu.tsx | 4 +- .../src/client/SignOutDialog.tsx | 4 +- .../ui-settings-account/src/client/index.ts | 22 ++++----- .../ui-settings-account/src/client/locales.ts | 2 + .../tests/account.client.spec.tsx | 6 +-- .../tests/apply.client.spec.ts | 26 ++++++++-- .../tests/expected/sign-out-unknown-en.txt | 1 + .../tests/expected/sign-out-unknown-zh.txt | 1 + .../tests/sign-out-dialog.client.spec.tsx | 6 +-- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 ++ .../client/ui-settings-models/README.zh.md | 4 ++ .../src/client/CustomProviderCard.tsx | 3 +- .../src/client/ProviderEditor.tsx | 3 +- .../src/client/operations.ts | 15 ++++-- .../ui-settings-models/src/client/store.ts | 6 ++- .../tests/components.client.spec.tsx | 23 +++++++++ .../tests/provider-form.client.spec.tsx | 24 ++++++++++ .../tests/store.client.spec.ts | 10 ++++ .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 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/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 + packages/llm/llm/README.zh.md | 2 + packages/llm/llm/src/index.ts | 8 ++-- 63 files changed, 348 insertions(+), 123 deletions(-) create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 40b3911071..45975fe2df 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -132,10 +132,8 @@ describe('web e2e: the composer model switch is the default for later sessions', const box = page.locator('[data-composer-input]').first() await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true) - // What removing the provider on the Models page leaves behind: the saved - // default still names the route, and nothing serves it any more. - // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every - // stored profile in place. + // Removing the selected provider preserves the other route and the saved + // selection. A merge update would retain the removed profile. await scaffold.ctx.settings.replace('llm-pi-ai', { providers: { [START_ROUTE]: { displayName: 'Origin Gateway', api: 'openai-completions', baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d642a66c82..d1ac605308 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 docs/architecture.md -architecture.md: 3e651fa4433f0d6bab0ba73cd85929ed45e9ee79 -architecture.zh.md: a30bb804c419f38e3f68a06c252e90e4993b9e42 +architecture.md: fd3e04f7287ce97628b162becf647284221c8f64 +architecture.zh.md: f5e3bf8a3a6746044d5fbd229997eeb21303576c diff --git a/docs/architecture.md b/docs/architecture.md index 3e651fa443..fd3e04f728 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,7 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). -Immutable requests retain cancellation. `Agent.activeProvider` exposes current provider ownership. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. +The loop sends immutable requests with live cancellation and reuses message-freeze evidence only for identities it fully froze; [agent-loop](../packages/core/agent-loop/README.md) owns request construction. `Agent.activeProvider` identifies the provider of the latest prepared request. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index a30bb804c4..f5e3bf8a3a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -116,7 +116,7 @@ turn/end `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 -循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 `Agent.activeProvider` 暴露当前提供方归属。 +循环发送不可变请求并保留实时取消能力,只对已完整冻结的消息对象身份复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。`Agent.activeProvider` 标识最近一次已准备请求的提供方。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e174fcd03e..ba7cde7178 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: c5535c610ed88f265efe5065e2ff3374030192d4 -event-producer-consumer.zh.md: b2e558f5e711a3cf3628f1b9da802c1d69693090 +event-producer-consumer.md: f8160835916b5f1e6e71583c2adf647e4d996316 +event-producer-consumer.zh.md: 7a17b855917b7cb35d0fe67a56a9040dcd615978 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c5535c610e..f816083591 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b2e558f5e7..7a17b85591 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ae6c4b51eb..d59f81da1a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1 -persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b +persistence-catalog.md: f71cb89c5494c77df8d036faddea811a110444e1 +persistence-catalog.zh.md: abd0280d094b9f24fea7d9abe59d2ad17af263c3 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 43b4f86125..f71cb89c54 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5304,7 +5304,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7195,7 +7195,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c655d97086..abd0280d09 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5306,7 +5306,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7197,7 +7197,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 428d512264..db3a0f152a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44144,7 +44144,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:380", + "packages/api/session-controller/src/types.ts:381", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -57727,7 +57727,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:385" + "packages/api/session-controller/src/types.ts:386" ] }, { diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 757474dd41..2a34dc3c29 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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 docs/subsystems/llm-streaming.md -llm-streaming.md: 07fc6599bc5d4eb5a4af9b566032004932052585 -llm-streaming.zh.md: 079a33599d1e5169af6402db2b2abdd7dc978369 +llm-streaming.md: 6ff93698cb877591fb85bc1a05d1dabc95b6561b +llm-streaming.zh.md: 400fcd5a96de0097ec827055fe6f96fc3eef0846 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 07fc6599bc..6ff93698cb 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -837,8 +837,9 @@ declare abstract class LlmAdapter { imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined; /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -1014,7 +1015,8 @@ fileRequestText(ref: FileAttachmentRef): string /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 079a33599d..400fcd5a96 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -843,8 +843,9 @@ declare abstract class LlmAdapter { imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined; /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -1020,7 +1021,8 @@ fileRequestText(ref: FileAttachmentRef): string /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index b178f956cf..c7614d54c7 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: 5f09de61395c3d729144203b77eeb299c217128e -README.zh.md: dfb5b97b5804b4512d17eab47866ee079fbc047d +README.md: 5b96af2040c95e6e9195d55207ead0e0acbdba12 +README.zh.md: 42961830f1731ad9ce3efe048ebde03490f80637 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 5f09de6139..5b96af2040 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,7 +68,7 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. -GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. Availability never substitutes another model or rewrites a Session selection. +GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection or a failed catalog query rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation with the same error code. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. These catalog requirements belong to GUI admission; direct LLM calls can use uncatalogued models. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index dfb5b97b58..42961830f1 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,7 +68,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 -GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。不可用的选择在投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。可用性变化不会替换模型或改写会话选择。 +GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。选择不可用或目录查询失败时,投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查,并使用相同错误码。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。这些目录要求属于 GUI 准入;直接 LLM 调用可使用未列入目录的模型。 ----- diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts index df751d86ba..e66c6961cb 100644 --- a/packages/api/session-controller/src/catalog.ts +++ b/packages/api/session-controller/src/catalog.ts @@ -1,5 +1,7 @@ /** Shared projection of the live LLM registry into the browser model catalog. */ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { LlmModelInfo } from '@deepseek-ai/dsh-llm' import type { Context } from '@deepseek-ai/cordis' import type { ModelCatalog, @@ -57,11 +59,12 @@ export async function buildModelCatalog( } } })) + const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) + .filter(group => group.models.length > 0) return { default: { ...defaultSelection }, - routableProviders: catalog.flatMap(item => item.kind === 'group' && item.group.models.length > 0 ? [item.group.id] : []), - groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) - .filter(group => group.models.length > 0), + routableProviders: groups.map(group => group.id), + groups, failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), } } @@ -74,6 +77,12 @@ export async function buildModelCatalog( */ export async function modelAvailable(ctx: Context, selection: ModelSelection): Promise { if (!ctx.llm.listProviders().some(provider => provider.id === selection.provider)) return false - const models = await ctx.llm.listModels(selection.provider) + let models: readonly LlmModelInfo[] + try { models = await ctx.llm.listModels(selection.provider) } + catch (error) { + throw new RemoteError('session/model-unavailable', + error instanceof Error ? error.message : String(error), + { provider: selection.provider, model: selection.model }) + } return models.some(model => model.id === selection.model) } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 6f51c15da4..2cc81e3097 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -279,7 +279,8 @@ export class SessionController extends TypertRemoteService { async initializeDefaultModel(provider: string): Promise { const catalog = await buildModelCatalog(this.ctx) const model = catalog.groups.find(group => group.id === provider)?.models[0] - if (model === undefined) throw new Error(`provider "${provider}" has no available models`) + if (model === undefined) throw new RemoteError('session/provider-models-unavailable', + `provider "${provider}" has no available models`, { provider }) await this.ctx.agentDefaultModel.initializeSelection({ provider, model: model.id, ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }, }) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c5b92ec0cc..e1021e8c0e 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -187,6 +187,7 @@ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + 'session/provider-models-unavailable': { readonly provider: string } 'session/model-unavailable': { readonly provider: string; readonly model: string } 'session/conflict': { readonly sessionId: SessionId diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 26d71d839e..0994ed1474 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -360,7 +360,9 @@ describe('Web session model selection', () => { await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' }) await controller.initializeDefaultModel('deepseek-official') expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' }) - await expect(controller.initializeDefaultModel('empty')).rejects.toThrow('no available models') + await expect(controller.initializeDefaultModel('empty')).rejects.toMatchObject({ + code: 'session/provider-models-unavailable', details: { provider: 'empty' }, + }) await ctx.fiber.dispose() }) @@ -612,6 +614,48 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it.each([1, 2])('reports catalog errors as model-unavailable at prompt check %s', async (check) => { + const { ctx, sessionId, agent } = await harness() + const followup = vi.fn() + Object.assign(agent, { followup }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', + }) + const list = vi.spyOn(ctx.llm, 'listModels') + if (check === 2) list.mockResolvedValueOnce([{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'Chat' }]) + list.mockRejectedValueOnce(new Error('credential storage offline')) + try { + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [{ type: 'text', text: 'hello' }], + }))).toMatchObject({ ok: false, error: { + code: 'session/model-unavailable', message: 'credential storage offline', + details: { provider: 'deepseek-official', model: 'deepseek-chat' }, + } }) + expect(list).toHaveBeenCalledTimes(check) + expect(followup).not.toHaveBeenCalled() + } finally { + list.mockRestore() + await ctx.fiber.dispose() + } + }) + + it('leaves uncatalogued adapters usable by core callers but unavailable to GUI selection', async () => { + const { ctx, sessionId } = await harness() + ctx.llm.registerAdapter(['uncatalogued'], new class extends LlmAdapter { + override async *stream(_options: GenerateOptions): AsyncIterable {} + }()) + try { + await expect(ctx.llm.resolveModelInfo('uncatalogued', 'custom-model')).resolves.toMatchObject({ id: 'custom-model' }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'uncatalogued', model: 'custom-model' }), cwd: '/tmp', + }) + expect(await remote.selectModel({ sessionId, provider: 'uncatalogued', model: 'custom-model' })) + .toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + } finally { + await ctx.fiber.dispose() + } + }) + it('refuses a prompt no adapter can route, and reports it on the directory', async () => { const { ctx, sessionId } = await harness() const remote = createSessionTestRemote(ctx, { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index 42bc9724df..dc7e753c88 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -150,7 +150,7 @@ export function ConversationContent(props: ConversationContentProps) { // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the // user clears it. - ? { blocked: composerBlock, ...(hero ? { placeholder: t('placeholder.hero') } : {}) } + ? { blocked: composerBlock, placeholder: t(hero ? 'placeholder.hero' : 'placeholder.default') } : hero ? { placeholder: t('placeholder.hero') } : {}), }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 17aadfb056..c7720a7db2 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -424,15 +424,14 @@ describe('ConversationRoot resident composer', () => { expect(dispatchCount()).toBe(before) }) - it('renders the composer inert with the blocker\u2019s own reason', () => { + it('keeps the normal placeholder while model availability blocks sending', () => { const b = mount(sessionSnapshotOf(), undefined, undefined, { composerBlock: { reason: 'select a model first' }, }) const box = b.view.getByRole('textbox') - // One disabled composer with the blocker's placeholder, never a second - // tree: the DOM survives the block being raised and cleared. + // Blocking keeps the mounted composer and its normal placeholder. expect(box.getAttribute('aria-disabled')).toBe('true') - expect(box.getAttribute('data-placeholder')).toBe('select a model first') + expect(box.getAttribute('data-placeholder')).toBe(zh['placeholder.default']) fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).not.toHaveBeenCalled() diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index db4fe73951..496f41b644 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/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/client/ui-model-selection/README.md -README.md: 3c73a42b34ad9075eaaab9e256767b7250ea0513 -README.zh.md: 87cccdeacde3183427a1ff08e1d1288e5cf170be +README.md: 25945f012b2c1f6cfc0f36203fac632c01a86101 +README.zh.md: 6c59586a08b7833f40491f61b5f57cd2b76e5735 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 3c73a42b34..25945f012b 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -33,11 +33,11 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp ### Model and effort -Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first; other providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. ### Unroutable sessions -The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. +The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Refreshes and refresh failures retain the last displayed selection and groups while sending remains blocked; retry revalidates availability. A Host reset clears that display. The account provider name follows the active locale in both selectors. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 87cccdeacd..6c59586a08 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -33,11 +33,11 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号置顶,其他提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 ### 不可路由的会话 -确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 +确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。刷新及刷新失败期间保留上次显示的选择和分组,同时继续禁发;重试会重新确认可用性。Host 重置时清空这些显示。两个选择器中的账号提供方名称均随当前语言显示。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 2223cd3c21..d209e23cc7 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -76,7 +76,8 @@ export function ModelSelect( const id = useId() const groups = useMemo(() => state.groups.toSorted((left, right) => - Number(right.id === 'deepseek-account') - Number(left.id === 'deepseek-account')), [state.groups]) + (left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2) + - (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)), [state.groups]) const choices = useMemo(() => groups.flatMap(group => group.models.map(model => ({ group, @@ -407,7 +408,7 @@ export function ModelSelect( )} {state.failures.map(failure => (
- {t('warning.groupLoad', { name: failure.name, message: failure.message })} + {t('warning.groupLoad', { name: failure.id === 'deepseek-account' ? t('provider.account') : failure.name, message: failure.message })}
))} @@ -416,7 +417,7 @@ export function ModelSelect( const headingId = `${id}-${group.id}` return (
-
{group.name}
+
{group.id === 'deepseek-account' ? t('provider.account') : group.name}
{group.models.map((model) => { const selected = state.current?.provider === group.id && state.current.model === model.id return ( diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 162ad5db7d..355dfc7fda 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -15,7 +15,7 @@ import type { ModelCatalogDirectory } from './catalog.ts' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Available durable selection, or the available default for an unselected Session. */ + /** Last catalog-confirmed selection, retained for display during same-Host refreshes. */ current: ModelSelection | null /** Saved effort caption retained when the selected model is unavailable. */ retainedEffort?: string @@ -145,11 +145,11 @@ export class ModelDirectory { : reasoning?.efforts.find(level => level.id === effort)?.name ?? effort if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { this.store.set({ - current: null, + current: catalog.value === null ? null : this.store.getSnapshot().current, ...retainedEffort === undefined ? {} : { retainedEffort }, routable: null, - groups: [], - failures: [], + groups: catalog.value?.groups ?? [], + failures: catalog.value?.failures ?? [], status: catalog.status === 'error' ? 'error' : 'loading', error: catalog.error, }) diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 201ac8c732..12f81cec2f 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -67,12 +67,13 @@ function descriptionOf( function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] { const rows: SelectOption[] = [] for (const group of directory.groups) { + const name = group.id === 'deepseek-account' ? t('provider.account') : group.name for (const model of group.models) { const description = descriptionOf(group.id, model, t) rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: description !== undefined ? `${group.name} · ${description}` : group.name, + detail: description !== undefined ? `${name} · ${description}` : name, ...(directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id @@ -83,7 +84,7 @@ function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): Sel for (const failure of directory.failures) { rows.push({ id: `failure/${failure.id}`, - label: failure.name, + label: failure.id === 'deepseek-account' ? t('provider.account') : failure.name, detail: t('option.loadError', { message: failure.message }), }) } diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 2ab727b353..9d60b95926 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -10,6 +10,7 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'provider.account': 'DeepSeek 账号', 'command.label': '模型', 'command.description': '选择本会话使用的模型', 'option.loadError': '目录加载失败:{message}', @@ -39,6 +40,7 @@ export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'provider.account': 'DeepSeek Account', 'command.label': 'Model', 'command.description': 'Select the model for this conversation', 'option.loadError': 'Catalog failed to load: {message}', diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index af7d3dee20..ffd02ee21b 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -73,16 +73,19 @@ async function bench(locale: 'zh' | 'en' = 'zh') { const projections = new Map>() // Whether the Host advertises available models for the current route. let routable = true + let catalogFailure = false + let groups = GROUPS let selectionFailure: RemoteError<'session/writer-held'> | undefined const sessionRemote = { modelCatalog: () => { calls.models += 1 + if (catalogFailure) return Promise.reject(new Error('catalog offline')) return Promise.resolve({ ok: true as const, value: { default: defaultSelection, routableProviders: routable ? ['deepseek-official'] : [], - groups: routable ? GROUPS : [], + groups: routable ? groups : [], failures: [], }, }) @@ -185,6 +188,8 @@ async function bench(locale: 'zh' | 'en' = 'zh') { setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection }, setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) }, address: (id: SessionId) => { addressed.add(id) }, + setGroups: (next: typeof GROUPS) => { groups = next }, + setCatalogFailure: (next: boolean) => { catalogFailure = next }, setRoutable: (next: boolean) => { routable = next }, blockOf: (key: string) => blocks.get(sid(key)), } @@ -280,6 +285,19 @@ describe('ui-model-selection dual entry', () => { }) }) + it.each(['en', 'zh'] as const)('localizes account provider details in the %s model popup', async (locale) => { + const b = await bench(locale) + try { + b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const options = await b.popup().options(projection('s1'), new AbortController().signal) + expect(options[0]?.detail).toContain(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') + } finally { + await b.ctx.fiber.dispose() + } + }) + it('both entries share one directory instance per session, isolated across sessions', async () => { const b = await bench() b.mint('a') @@ -330,7 +348,9 @@ describe('ui-model-selection dual entry', () => { next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, }) expect(face.directory.getSnapshot()).toMatchObject({ - current: null, + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + groups: GROUPS, + routable: null, status: 'loading', }) @@ -342,6 +362,30 @@ describe('ui-model-selection dual entry', () => { }) }) + it('retains the last catalog on refresh failure, blocks sending, and recovers on retry', async () => { + const b = await bench() + try { + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + await b.ctx.modelDirectories.directoryFor(sid('s1')).load() + b.setCatalogFailure(true) + b.remote.emit('credentials/record-updated', ['DEEPSEEK_API_KEY']) + await vi.waitFor(() => { + expect(face.directory.getSnapshot()).toMatchObject({ + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + groups: GROUPS, routable: null, status: 'error', error: 'catalog offline', + }) + }) + expect(b.blockOf('s1')).toBeDefined() + b.setCatalogFailure(false) + await b.ctx.modelDirectories.directoryFor(sid('s1')).load() + expect(face.directory.getSnapshot().routable).toBe(true) + expect(b.blockOf('s1')).toBeUndefined() + } finally { + await b.ctx.fiber.dispose() + } + }) + it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => { const b = await bench() const first = b.mint('s1') diff --git a/packages/client/ui-model-selection/tests/expected/account-first.txt b/packages/client/ui-model-selection/tests/expected/account-first.txt index 304c901c9f..6a8a6e2c70 100644 --- a/packages/client/ui-model-selection/tests/expected/account-first.txt +++ b/packages/client/ui-model-selection/tests/expected/account-first.txt @@ -1,8 +1,8 @@ deepseek-account-1 deepseek-account-2 -custom-1 -custom-2 deepseek-official-1 deepseek-official-2 +custom-1 +custom-2 another-1 another-2 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 2d8df4fad0..feef74b1bd 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' // The seat's key domain is model ∪ common; the stub mirrors the real lookup @@ -463,7 +463,7 @@ it('shows the unselected model control with the inherited effort', async () => { }) -it('places account models first while preserving other provider and model order', async () => { +it('places account and official models before third-party models', async () => { const groups = ['custom', 'deepseek-official', 'deepseek-account', 'another'].map(id => ({ id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })), })) @@ -472,9 +472,22 @@ it('places account models first while preserving other provider and model order' fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) expect(names).toEqual([ - 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', - 'deepseek-official-1', 'deepseek-official-2', 'another-1', 'another-2', + 'deepseek-account-1', 'deepseek-account-2', 'deepseek-official-1', 'deepseek-official-2', + 'custom-1', 'custom-2', 'another-1', 'another-2', ]) expect(groups.map(group => group.id)).toEqual(['custom', 'deepseek-official', 'deepseek-account', 'another']) await expect(`${names.join('\n')}\n`).toMatchFileSnapshot('./expected/account-first.txt') }) + +it.each([en, zh])('localizes the account group while preserving external names', (copy) => { + const groups = ['deepseek-account', 'custom'].map(id => ({ + id, name: id === 'deepseek-account' ? 'DeepSeek Account' : 'My Gateway', + models: [{ id: 'model', name: 'Model' }], + })) + render( key in copy ? copy[key as keyof typeof copy] : key} />) + fireEvent.click(screen.getByRole('button', { name: copy['trigger.selectAria'] })) + expect(screen.getByRole('group', { name: copy['provider.account'] })).toBeTruthy() + expect(screen.getByRole('group', { name: 'My Gateway' })).toBeTruthy() +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a9fcd71d5a..a3c8fbdf6f 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/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/client/ui-settings-account/README.md -README.md: d54687d083897842518a0c95dfc84d39e985f3a1 -README.zh.md: c4dbcf234fb72e826ccd742b70740744e98e8fcb +README.md: 615d2ef3466fc748d7b8c65c65c356afdd4f3cf3 +README.zh.md: 39d8a94b2e8d98896b13b8c76f7e5ff6390b5d0d diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index d54687d083..615d2ef346 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -34,7 +34,7 @@ The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, a The account card’s More account information link opens `https://platform.deepseek.com` in the system browser on Desktop or a new tab on Web. -Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed queries keep the menu available; failed sign-out keeps the dialog available for retry. +Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed impact queries still open confirmation with an explicit unknown-task warning; failed sign-out keeps the dialog available for retry. ## Understand the implementation @@ -74,3 +74,5 @@ No model request prefix changes. The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership. Usage and top-up show a centered 24px loading indicator without visible loading text until the native document loads; the return action remains available. The loading SVG is embedded locally from Figma node 2957:72553. + +Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded in diagnostics and do not mark the signed-in account as failed. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index c4dbcf234f..39d8a94b2e 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -34,7 +34,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 账号卡片的“更多账号信息”链接在 Desktop 中通过系统浏览器打开 `https://platform.deepseek.com`,在 Web 中打开新标签页。 -退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。查询失败保留菜单;退登失败保留弹窗以便重试。 +退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。影响查询失败仍打开确认弹窗,并明确提示无法确认任务状态;退登失败保留弹窗以便重试。 ## 理解实现 @@ -74,3 +74,5 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl [桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。 用量和充值在原生页面加载完成前显示居中的 24px 加载图标,不显示加载文字;返回操作始终可用。加载 SVG 从 Figma 节点 2957:72553 内嵌到本地样式。 + +默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败会记录诊断日志,不会将已登录账号标记为失败。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 6122728558..7570a13190 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -27,12 +27,12 @@ export function AccountMenu({ const [open, setOpen] = useState(false) const [busy, setBusy] = useState(false) const [failed, setFailed] = useState(false) - const [signOutImpact, setSignOutImpact] = useState() + const [signOutImpact, setSignOutImpact] = useState() const requestSignOut = async () => { setBusy(true) setFailed(false) try { setSignOutImpact(await hasRunningAccountTasks()); setOpen(false) } - catch { setFailed(true) } + catch (_error) { setSignOutImpact('unknown'); setOpen(false) } finally { setBusy(false) } } return
diff --git a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx index f9b59e8ea8..d364f41ede 100644 --- a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx @@ -6,7 +6,7 @@ import css from './SignInDialog.module.css' /** @param props - task impact at opening, localized copy, and account actions. @returns sign-out confirmation. */ export function SignOutDialog({ running, signOut, close, t }: { - running: boolean + running: boolean | 'unknown' signOut: () => Promise close: () => void t: (key: AccountKey) => string @@ -29,7 +29,7 @@ export function SignOutDialog({ running, signOut, close, t }: {
-

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

+

{t(running === 'unknown' ? 'signOutUnknownDescription' : running ? 'signOutRunningDescription' : 'signOutDescription')}

{failed &&

{t('failed')}

}
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index da26bbd8d8..c718292cbf 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -72,19 +72,19 @@ export function apply(ctx: Context): void { for await (const frame of stream) { revision++ refreshing = undefined - let initializationFailed = false - if (frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' - && snapshot.view?.attempt?.phase !== 'succeeded') { - try { - const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') - initializationFailed = !initialized.ok - } catch (_error) { - initializationFailed = true - } - } - publish({ ...snapshot, view: frame.value, details: undefined, failed: initializationFailed }) + const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded' + publish({ ...snapshot, view: frame.value, details: undefined, failed: false }) browser.update(frame.value) frame.accept() + if (initialize) void (async () => { + try { + const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') + if (!initialized.ok) console.info('[deepseek-account] default model initialization failed', { reason: 'refused' }) + } catch (_error) { + console.info('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) + } + })() void refresh() } })().catch(() => { if (!stream.signal.aborted) publish({ ...snapshot, failed: true }) }) diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index b93c8e61c0..e3a07f3f8f 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -10,6 +10,7 @@ export const en = { settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu', nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in', signIn: 'Sign in', signOut: 'Sign out', + signOutUnknownDescription: 'Could not check running tasks. Signing out may interrupt tasks using this account. Sign out now?', signOutDescription: 'Signing out will not delete any data. You can sign in to this account again.', signOutRunningDescription: 'Tasks are currently running. Signing out will interrupt them. Sign out now?', cancel: 'Cancel', open: 'Open browser', initializing: 'Starting sign in…', waiting: 'Continue in your browser', @@ -36,6 +37,7 @@ export const zh: Record = { settings: '设置', contactUs: '联系我们', menu: '账号菜单', nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录', signIn: '登录', signOut: '退出登录', + signOutUnknownDescription: '暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?', signOutDescription: '退出登录不会丢失任何数据,你仍可以登录此账号。', signOutRunningDescription: '当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?', cancel: '取消', open: '打开浏览器', initializing: '正在发起登录…', waiting: '请在浏览器中继续', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 3757579a79..dbf81ece0e 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -51,14 +51,14 @@ it('starts sign-in and disables cancellation during persistence', async () => { expect(screen.queryByRole('button', { name: en.signIn })).toBeNull() }) -it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { +it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { const signOut = vi.fn(() => Promise.resolve()) const openSettings = vi.fn() const operations = mount({ status: 'credential-stored', attempt: null }, copy) cleanup() const { AccountMenu } = await import('../src/client/AccountMenu.tsx') render( running} + hasRunningAccountTasks={async () => { if (running === 'unknown') throw new Error('offline'); return running }} useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings} t={key => key in copy ? copy[key as AccountKey] : key} />) fireEvent.click(screen.getByRole('button', { name: copy.menu })) @@ -73,7 +73,7 @@ it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running } fireEvent.click(screen.getByRole('button', { name: copy.menu })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: copy.signOut })) }) expect(signOut).not.toHaveBeenCalled() - expect(screen.getByText(running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() + expect(screen.getByText(running === 'unknown' ? copy.signOutUnknownDescription : running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(signOut).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 7f01fc5f6e..6a33cda9d4 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -4,13 +4,17 @@ import { expect, it, vi } from 'vitest' import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import { apply, type AccountSectionInjected } from '../src/client/index.ts' -it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { +it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const observed: { status: string | undefined; failed: boolean | undefined }[] = [] const abort = new AbortController() const done = Promise.withResolvers() const release = Promise.withResolvers() + const initialization = Promise.withResolvers() const initializeDefaultModel = vi.fn(async () => { + if (outcome === 'pending') await initialization.promise if (outcome === 'disconnected') throw new Error('connection lost') - return { ok: outcome === 'accepted', value: undefined } + return { ok: outcome === 'accepted' || outcome === 'pending', value: undefined } }) const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } const signedIn: AccountView = { @@ -38,7 +42,11 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m $stream: () => ({ signal: abort.signal, dispose: () => { abort.abort(); release.resolve(undefined) }, async *[Symbol.asyncIterator]() { - for (const value of frames) yield { value, accept: () => { if (value.status === 'signed-out') done.resolve(undefined) } } + for (const value of frames) yield { value, accept: () => { + const snapshot = operations?.hooks.account.getSnapshot() + observed.push({ status: snapshot?.view?.status, failed: snapshot?.failed }) + if (value.status === 'signed-out') done.resolve(undefined) + } } await release.promise }, }), @@ -48,8 +56,20 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m apply(ctx as never) await done.promise expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account') + expect(observed).toEqual([ + { status: 'credential-stored', failed: false }, + { status: 'credential-stored', failed: false }, + { status: 'signed-out', failed: false }, + ]) + if (outcome === 'accepted' || outcome === 'pending') expect(log).not.toHaveBeenCalled() + else expect(log).toHaveBeenCalledWith('[deepseek-account] default model initialization failed', { + reason: outcome === 'refused' ? 'refused' : 'disconnected', + }) expect(operations?.hooks.account.getSnapshot()).toMatchObject({ view: { status: 'signed-out' }, failed: false }) } finally { + initialization.resolve(undefined) + await Promise.resolve() for (const dispose of disposers.reverse()) dispose() + log.mockRestore() } }) diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt new file mode 100644 index 0000000000..d8b1fd328b --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt @@ -0,0 +1 @@ +Sign outCould not check running tasks. Signing out may interrupt tasks using this account. Sign out now?CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt new file mode 100644 index 0000000000..3578b1f9bf --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt @@ -0,0 +1 @@ +退出登录暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?取消退出登录 diff --git a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx index 9ee95e897f..d2fbb29028 100644 --- a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx @@ -6,16 +6,16 @@ import { SignOutDialog } from '../src/client/SignOutDialog.tsx' import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) -function mount(running: boolean, copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { +function mount(running: boolean | 'unknown', copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { const close = vi.fn() render( copy[key]} />) return { signOut, close } } -it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))( +it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))( 'confirms sign-out with localized task impact: $running', async ({ copy, running }) => { const props = mount(running, copy) await expect(`${screen.getByRole('dialog').textContent}\n`) - .toMatchFileSnapshot(`./expected/sign-out-${running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) + .toMatchFileSnapshot(`./expected/sign-out-${running === 'unknown' ? 'unknown' : running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) expect(props.signOut).not.toHaveBeenCalled() await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(props.signOut).toHaveBeenCalledOnce() diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c84f99abf1..3147eeab54 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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/client/ui-settings-models/README.md -README.md: ccce6981a618fd2dfa451f1600ae19e443d77973 -README.zh.md: 0d2f64a1fe4583493b192f33f128f86157a65f8f +README.md: 289a0ec0b3603590dddcab848a79235ca4579bcf +README.zh.md: 10c61a464d48933a692a265b5d5b0812bf79cdbb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index ccce6981a6..289a0ec0b3 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -25,6 +25,10 @@ English | [中文](README.zh.md) ## Use this package +Saving credentials or a custom provider completes independently of default-model initialization. Initialization failures are logged; they do not report a successful save as failed or prevent closing the editor. The user can select an available model from the composer. + +DeepSeek Account appears first and DeepSeek second in the provider list; third-party providers retain their directory order. + Open the Models page from the Settings navigation to see every configured provider as a row. A whole-section provider whose key is not configured anywhere renders as its open setup card instead, but only in the first-run posture and only until the user closes that card. Each card kind owns its own open state, so closing one never discards a draft in another. A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 0d2f64a1fe..10c61a464d 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -25,6 +25,10 @@ kind: "package-reference" ## 使用本包 +凭据或自定义提供方保存成功后,不等待默认模型初始化即可完成操作。初始化失败记录日志,不会将已成功的保存报告为失败,也不会阻止编辑器关闭。用户可在 composer 中选择可用模型。 + +提供方列表中 DeepSeek 账号排第一,DeepSeek 排第二;第三方提供方保留目录中的相对顺序。 + 从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index c84e72f743..38def6a132 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -178,7 +178,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // and the retry above now goes straight back to this write. if (stored !== undefined) return stored } - return operations.initializeModel(route.trim()) + void operations.initializeModel(route.trim()) + return undefined } const create = async (): Promise => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 93e1fa7d1e..a91cc0d744 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -288,8 +288,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (keyValue.length > 0) { const stored = await operations.storeCredential(keyRef, keyValue) if (stored !== undefined) return stored - const initialized = await operations.initializeModel(props.provider) - if (initialized !== undefined) return initialized + void operations.initializeModel(props.provider) } setKeyDraft('') return undefined diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 75d87ac821..071ab72454 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -32,8 +32,11 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { - /** @param provider - newly configured provider. @returns refusal text, or undefined after initialization. */ - initializeModel(provider: string): Promise + /** + * @param provider - newly configured provider. + * @returns after optional initialization; failures are logged without changing the save outcome. + */ + initializeModel(provider: string): Promise /** * Read one credential reference's state. * @param ref - credential reference name. @@ -84,8 +87,12 @@ export interface ModelsOperations { export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { initializeModel: async (provider) => { - const response = await ctx.remote.session.initializeDefaultModel(provider) - return response.ok ? undefined : response.error.message + try { + const response = await ctx.remote.session.initializeDefaultModel(provider) + if (!response.ok) console.info('[models] default model initialization failed', { provider, reason: 'refused' }) + } catch (_error) { + console.info('[models] default model initialization failed', { provider, reason: 'disconnected' }) + } }, describeCredential: async (ref) => { const response = await ctx.remote.credentials.describe([ref]) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index f3e948b79f..f693b5960d 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -37,7 +37,7 @@ export interface ProviderDirectoryEntry { * Join declared configurable providers with the currently registered routes. * @param registered - live provider routes in registration order. * @param directory - declared configurable providers in declaration order. - * @returns declared rows followed by live routes with no declaration. + * @returns account and official routes first, then other routes in their original order. */ export function joinProviderDirectory( registered: readonly LlmProviderInfo[], @@ -64,7 +64,9 @@ export function joinProviderDirectory( active: true, }) } - return rows + return rows.toSorted((left, right) => + (left.provider === 'deepseek-account' ? 0 : left.provider === 'deepseek-official' ? 1 : 2) + - (right.provider === 'deepseek-account' ? 0 : right.provider === 'deepseek-official' ? 1 : 2)) } /** One provider row the page renders. */ diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 88e43e599f..c1752b9204 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -1111,6 +1111,29 @@ describe('ModelsSection', () => { expect(baseURL.value).toBe('') }) + it.each(['refused', 'disconnected', 'pending'] as const)('finishes credential saving when default initialization is %s', async (outcome) => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const pending = Promise.withResolvers() + const { ctx, set } = await mountDeepSeekCard() + const initialize = vi.spyOn(ctx.remote.session, 'initializeDefaultModel') + if (outcome === 'pending') initialize.mockReturnValue(pending.promise) + else if (outcome === 'disconnected') initialize.mockRejectedValue(new Error('offline')) + else initialize.mockResolvedValue({ ok: false, + error: new RemoteError('session/provider-models-unavailable', 'empty catalog', { provider: 'deepseek-official' }) }) + try { + fireEvent.change(await screen.findByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() }) + expect(set).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledExactlyOnceWith('deepseek-official') + expect(screen.queryByText('empty catalog')).toBeNull() + } finally { + if (outcome === 'pending') { pending.reject(new Error('closed')); await Promise.resolve() } + initialize.mockRestore() + log.mockRestore() + } + }) + it('rejects an invalid draft before writing', async () => { const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index ae71cf0557..370027cdd7 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -847,6 +847,30 @@ describe('hand-declared providers', () => { return { ...scripted, onClose } } + it('closes a saved custom provider when default initialization is refused', async () => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const { face, mutate, set, onClose } = mountCard() + const initialize = vi.spyOn(ctxWith(face).remote.session, 'initializeDefaultModel') + .mockResolvedValue({ ok: false, error: new RemoteError('session/provider-models-unavailable', + 'empty catalog', { provider: 'acme-gateway' }) }) + try { + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'model' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(mutate).toHaveBeenCalledOnce() + expect(set).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledExactlyOnceWith('acme-gateway') + expect(screen.queryByText('empty catalog')).toBeNull() + } finally { + initialize.mockRestore() + log.mockRestore() + } + }) + it('writes the whole profile and the key under the derived reference', async () => { const { mutate, set, onClose } = mountCard() diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index d181edc12e..5a56062da4 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -16,6 +16,16 @@ it.each([false, true])('retains configuration diagnostics when the route is acti }]) }) +it('places account and official before third-party providers', () => { + const providers = ['custom', 'deepseek-official', 'deepseek-account', 'openai'] + const directory = providers.map(provider => ({ + provider, displayName: provider, settingsNs: 'fixture', settingsPath: [], + })) + expect(joinProviderDirectory([], directory).map(row => row.provider)) + .toEqual(['deepseek-account', 'deepseek-official', 'custom', 'openai']) + expect(directory.map(row => row.provider)).toEqual(providers) +}) + let nextRpc = 0 function ok(value: T): RpcResponse { return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5706f2a77d..f05c4ed4f6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1398,7 +1398,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async listModels(provider: string): Promise', - description: 'Discover models advertised by one registered provider. Catalog membership is advisory and never changes routing or request validation.', + description: 'Discover models advertised by one registered provider. Catalog membership does not constrain core routing. Catalog-driven entry points may restrict selection and submission to the advertised models.', parameters: [{ name: 'provider', description: 'registered provider route to inspect.' }], returns: 'detached model metadata in adapter-preferred order.', }, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index fcc298d024..8bfe705158 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: 7ded16b5e1d8c87b924aeb02c8eda0a3033bd3ee -README.zh.md: de612f7b2153eab35e8a00db71aeb1bb92824eb1 +README.md: be2694c0f7cd1fd19b7f17cabb0e2598e995a065 +README.zh.md: e07afc9f9084c04bd0605de284e40c4f8c44b445 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7ded16b5e1..be2694c0f7 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -47,7 +47,7 @@ Choose this adapter for DeepSeek's official API or a gateway that supports the s filesApiTimeoutMs: 60000 ``` -A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and unlisted model ids still pass through as text-only routes. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. +A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and core calls still pass unlisted model ids through as text-only routes. GUI selection and submission require a catalog entry. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. | Field | Default | Meaning | |---|---|---| diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index de612f7b21..e07afc9f90 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -47,7 +47,7 @@ kind: "package-reference" filesApiTimeoutMs: 60000 ``` -请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,未列出的模型 id 仍作为纯文本路由原样通过。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 +请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,核心调用中未列出的模型 id 仍作为纯文本路由原样通过。GUI 选择与提交要求模型具有目录条目。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 | 字段 | 默认值 | 含义 | |---|---|---| diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 6fac0fb24a..0adf149bab 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/README.md -README.md: cdda682cebc720d51692a1b0c4c8691fa84359a3 -README.zh.md: 8c679ecc6910376f7270450ae5be867d0da353fc +README.md: 1abdc8e332410ccd69066af6944bc4a32221635e +README.zh.md: 320a232c13fd145b65d53860c0cb1a4f7bfeb5a7 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index cdda682ceb..1abdc8e332 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,6 +25,8 @@ Use `@deepseek-ai/dsh-llm` to stream model calls through configured provider ada ## Use this package +`listModels` describes models offered by catalog-driven interfaces. Core resolution and streaming can still accept unlisted ids. The GUI requires catalog membership for selection and submission; adapters intended for GUI use must implement `listModels` and advertise their available models. The base implementation returns an empty list and therefore offers no GUI models. + Any composition that calls a model provider — an agent loop, a session-title generator, a compaction summarizer — streams its requests through this service. Mount it together with at least one provider adapter; the service itself has no configuration and no provider wire code. ### When to choose it diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 8c679ecc69..320a232c13 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +`listModels` 描述目录驱动界面提供的模型。核心解析与流式调用仍可接受未列出的 ID。GUI 的模型选择与提交要求模型出现在目录中;供 GUI 使用的适配器必须实现 `listModels`,公布其可用模型。基类实现返回空列表,因此不向 GUI 提供模型。 + 任何调用模型提供方的组合——agent loop(智能体循环)、会话标题生成器、压缩(compaction)摘要器——都会通过本服务流式发起请求。与至少一个提供方适配器一起挂载它;服务本身没有任何配置,也不包含提供方协议代码。 ### 何时选择 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 5f633619ff..64252cb643 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -235,8 +235,9 @@ export abstract class LlmAdapter { /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -689,7 +690,8 @@ export class LlmRuntime extends TypertRemoteService { /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ From f21098e9d32372ebe86605f81e392d4abecd88d7 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:41:33 +0800 Subject: [PATCH 07/30] fix(account): expire rejected credentials and hide signed-out provider --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/account-backend.ts | 4 ++ apps/desktop/tests/account-backend.spec.ts | 7 +++ apps/desktop/tests/welcome-startup.spec.ts | 24 +++++++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tests/browser-plugin.client.spec.ts | 21 +++++++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 + .../client/ui-settings-account/README.zh.md | 2 + .../src/client/AccountMenu.tsx | 8 ++- .../ui-settings-account/src/client/locales.ts | 2 + .../tests/account.client.spec.tsx | 11 ++++ .../tests/expected/expired-en.txt | 1 + .../tests/expected/expired-zh.txt | 1 + .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../ui-settings-models/src/client/store.ts | 2 +- .../tests/store.client.spec.ts | 26 ++++++++- .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 + .../deepseek-account-platform/README.zh.md | 2 + .../deepseek-account-platform/src/details.ts | 8 ++- .../deepseek-account-platform/src/index.ts | 37 +++++++++++-- .../deepseek-account-platform/src/protocol.ts | 9 +++ .../tests/account.spec.ts | 55 +++++++++++++++++++ .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 + .../credentials/deepseek-account/README.zh.md | 2 + .../credentials/deepseek-account/src/types.ts | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 35 files changed, 236 insertions(+), 32 deletions(-) create mode 100644 packages/client/ui-settings-account/tests/expected/expired-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/expired-zh.txt diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index d6d13bc0fb..1bf1514fdd 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 6107698a58cbbe76215d9f23607dabfb3031f8fd -README.zh.md: d85acc25c3bfdebf8929d74cba10e36ca7e8c64a +README.md: 5b9805dab3ee15bc32bdae9a62233ba2c64cadb0 +README.zh.md: bb3500dc834bc2d00f79984b4733715197c190e8 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6107698a58..5b9805dab3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -14,6 +14,8 @@ Embedded Platform documents use a dedicated non-persistent WebContentsView sessi Desktop Host Platform API requests use the same `x-client-platform` mapping as update-policy requests. The account provider owns [API-only header configuration](../../packages/credentials/deepseek-account-platform/README.md#use-this-package). +A server-expired account credential returns to Welcome when no official API key is configured; an available API key keeps the workspace open. Explicit sign-out follows the same rule. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d85acc25c3..bb3500dc83 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -14,6 +14,8 @@ Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-client-platform` 映射。账号 provider 管理[仅 API 使用的请求头配置](../../packages/credentials/deepseek-account-platform/README.zh.md#use-this-package)。 +账号凭据被服务端判定失效后,未配置官方 API key 时返回 Welcome;有可用 API key 时保持工作区打开。主动退出登录遵循相同规则。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 0db791f631..7b60e55db7 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -20,6 +20,9 @@ export function accountView(value: unknown): AccountView { } validateBrowserDestination(value.links.usageUrl) validateBrowserDestination(value.links.topUpUrl) + if ('signOutReason' in value && value.signOutReason !== 'expired') { + throw new Error('desktop account: invalid sign-out reason') + } const attempt = value.attempt if (attempt !== null && (typeof attempt !== 'object' || !('id' in attempt) || typeof attempt.id !== 'string' || !('phase' in attempt) || !['initializing', 'waiting-browser', 'exchanging', 'committing', 'succeeded', 'cancelled', 'expired', 'failed'].includes(String(attempt.phase)) @@ -34,6 +37,7 @@ export function accountView(value: unknown): AccountView { const parsed = value as AccountView return { status: parsed.status, + ...parsed.signOutReason === undefined ? {} : { signOutReason: parsed.signOutReason }, links: { usageUrl: parsed.links.usageUrl, topUpUrl: parsed.links.topUpUrl }, attempt: parsed.attempt === null ? null : { id: parsed.attempt.id, phase: parsed.attempt.phase, diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index 9c25ae1026..11217454ac 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -24,3 +24,10 @@ it('uses account Remote commands without returning additional wire fields', asyn expect(await backend.start('en')).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) expect(requests).toEqual([{ namespace: 'account', method: 'startSignIn', args: { locale: 'en', callbackOrigin: 'http://127.0.0.1:1234', loginSource: 'desktop' } }]) }) + +it('preserves the safe expiration reason and rejects unknown reasons', () => { + const state = { status: 'signed-out', attempt: null, signOutReason: 'expired', + links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } + expect(accountView(state)).toEqual(state) + expect(() => accountView({ ...state, signOutReason: 'unexpected' })).toThrow('invalid sign-out reason') +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 5e12c279d6..5d29254c2f 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -13,6 +13,7 @@ const state = vi.hoisted(() => ({ beforeRead: vi.fn(async () => {}), beforeWelcome: vi.fn(async () => {}), copy: vi.fn(), + accountListener: undefined as ((value: AccountView) => void) | undefined, accountState: vi.fn<() => Promise>().mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' }, }), @@ -24,6 +25,7 @@ const state = vi.hoisted(() => ({ closeWelcome: vi.fn(), welcomeLocale: undefined as DesktopLocale | undefined, preference: 'zh', + hasApiKey: false, handlers: new Map unknown>(), listeners: new Map void>(), contents: undefined as { mainFrame: { url: string } } | undefined, @@ -87,7 +89,9 @@ vi.mock('../src/host-process.ts', () => ({ start = state.startHost stop = state.stopHost fetch() { - return Promise.resolve(Response.json({ loggedIn: false, hasApiKey: false, writable: true, localePreference: state.preference })) + return Promise.resolve(Response.json({ + loggedIn: false, hasApiKey: state.hasApiKey, writable: true, localePreference: state.preference, + })) } }, })) @@ -95,10 +99,13 @@ vi.mock('../src/welcome-backend.ts', () => ({ connectDesktopWelcome: async () => ({ read: async () => { await state.beforeRead() - return { loggedIn: false, hasApiKey: false, writable: true, localePreference: state.preference } + return { loggedIn: false, hasApiKey: state.hasApiKey, writable: true, localePreference: state.preference } }, save: async () => ({ ok: true }), - account: { watch: () => () => {}, state: state.accountState }, + account: { + watch: (listener: (value: AccountView) => void) => { state.accountListener = listener; return () => {} }, + state: state.accountState, + }, }), })) vi.mock('node:fs/promises', async importOriginal => ({ @@ -201,4 +208,15 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho changed(event, 'en') expect(state.menu).toHaveBeenCalledTimes(initialMenus + 1) expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'en' }) + const welcomeCount = state.beforeWelcome.mock.calls.length + state.hasApiKey = true + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.advanceTimersByTimeAsync(0) + expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount) + state.hasApiKey = false + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.waitFor(() => { expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount + 1) }) + }) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3b89b5ced5..99b74a71ee 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 57e2c00c98ab2cf09757d78e93fff042156918b1 -config-catalog.zh.md: f197659787958d902a3069ac889b442dcad46d6b +config-catalog.md: 982333d8b645530f0e7a51440b1cfc9f74c86a1b +config-catalog.zh.md: bf195a16ebbc9e09eb8d6e5b79bb2671ee966b77 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57e2c00c98..982333d8b6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -639,7 +639,7 @@ export interface Config { } ``` -Source: [`packages/credentials/deepseek-account-platform/src/index.ts:23`](../packages/credentials/deepseek-account-platform/src/index.ts) +Source: [`packages/credentials/deepseek-account-platform/src/index.ts:24`](../packages/credentials/deepseek-account-platform/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f197659787..bf195a16eb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -641,7 +641,7 @@ export interface Config { } ``` -来源: [`packages/credentials/deepseek-account-platform/src/index.ts:23`](../packages/credentials/deepseek-account-platform/src/index.ts) +来源: [`packages/credentials/deepseek-account-platform/src/index.ts:24`](../packages/credentials/deepseek-account-platform/src/index.ts) diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index ffd02ee21b..eca46c5e64 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -298,6 +298,27 @@ describe('ui-model-selection dual entry', () => { } }) + it('removes account models from the picker after sign-out', async () => { + const b = await bench('en') + try { + b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }, ...GROUPS]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const before = await b.popup().options(projection('s1'), new AbortController().signal) + expect(before.some(option => option.detail?.includes('DeepSeek Account'))).toBe(true) + b.setGroups(GROUPS) + b.remote.emit('credentials/record-updated', ['deepseek-account-platform']) + await vi.waitFor(() => { + expect(b.ctx.modelDirectories.directoryFor(sid('s1')).store.getSnapshot().groups).toEqual(GROUPS) + }) + const after = await b.popup().options(projection('s1'), new AbortController().signal) + expect(after.some(option => option.detail?.includes('DeepSeek Account'))).toBe(false) + expect(after.length).toBeGreaterThan(0) + } finally { + await b.ctx.fiber.dispose() + } + }) + it('both entries share one directory instance per session, isolated across sessions', async () => { const b = await bench() b.mint('a') diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a3c8fbdf6f..eb121fc25e 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/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/client/ui-settings-account/README.md -README.md: 615d2ef3466fc748d7b8c65c65c356afdd4f3cf3 -README.zh.md: 39d8a94b2e8d98896b13b8c76f7e5ff6390b5d0d +README.md: 54f4461573de456afc29fadabb28d29528199e74 +README.zh.md: 31fb4e76c97fb65aa278bf8c354e6dab169764a4 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 615d2ef346..54f4461573 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -7,6 +7,8 @@ kind: "package-reference" English | [中文](README.zh.md) +Server-expired account credentials show a localized sign-in reminder toast and clear the displayed account details. + ## Summary The Account settings section displays DeepSeek login state and offers browser sign-in and cancellation; the sidebar account menu provides Platform sign-out. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 39d8a94b2e..31fb4e76c9 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +账号凭据被服务端判定失效后,显示本地化的重新登录提示 toast,并清空显示的账号详情。 + ## 概述 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器登录和取消;侧边栏账号菜单提供 Platform 退出登录。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 7570a13190..6a1476175a 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -1,6 +1,6 @@ /** Sidebar account launcher and locally authoritative sign-out action. */ -import { useState } from 'react' -import { Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' +import { useEffect, useState } from 'react' +import { Toast, Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AccountSectionInjected } from './AccountSection.tsx' import { SignOutDialog } from './SignOutDialog.tsx' @@ -21,6 +21,9 @@ export function AccountMenu({ }: AccountMenuProps) { const account = useAccount(state => state) const signedIn = account.view?.status === 'credential-stored' + const expired = account.view?.status === 'signed-out' && account.view.signOutReason === 'expired' + const [expiryNotice, setExpiryNotice] = useState(false) + useEffect(() => { setExpiryNotice(expired) }, [expired]) const profile = account.details?.profile const label = profile === undefined ? null : profile.status === 'ready' ? profile.value.name ?? profile.value.contact ?? t('signedIn') : t('signedIn') @@ -36,6 +39,7 @@ export function AccountMenu({ finally { setBusy(false) } } return
+ {expiryNotice && { setExpiryNotice(false) }} />} { setOpen(value => !value) }}> diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index e3a07f3f8f..fad00a6775 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -1,5 +1,6 @@ /** Account settings copy, owned by the account feature. */ export const en = { + sessionExpired: 'You have signed out of your account, please log in again.', close: 'Close', addApiKey: 'Add API Key', retry: 'Sign in again', loginTitle: 'Start creating', loginDescription: 'Sign in to create, edit, and share your design projects. Everything is saved locally.', browserTitle: 'Waiting for sign in', browserPrompt: 'Page did not open automatically? ', copyLink: 'Copy sign-in link', copiedLink: 'Link copied', copyFailed: 'Copy failed', @@ -27,6 +28,7 @@ export const en = { export type AccountKey = keyof typeof en /** Chinese account settings copy. */ export const zh: Record = { + sessionExpired: '登录信息已失效,请重新登录', close: '关闭', addApiKey: '添加 API Key', retry: '重新登录', loginTitle: '开始你的创作', loginDescription: '登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。', browserTitle: '等待登录', browserPrompt: '没有自动打开新页面?', copyLink: '复制登录链接', copiedLink: '链接已复制', copyFailed: '复制失败', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index dbf81ece0e..3cfb77c5fc 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -243,3 +243,14 @@ it('opens more account information externally without invoking the embedded Plat fireEvent.click(link) expect(platform.open).not.toHaveBeenCalled() }) + +it.each([en, zh])('shows a localized toast when the account credential expires', async (copy) => { + const operations = mount({ status: 'signed-out', attempt: null, signOutReason: 'expired' }, copy) + cleanup() + const { AccountMenu } = await import('../src/client/AccountMenu.tsx') + render( selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={() => {}} + t={key => key in copy ? copy[key as AccountKey] : key} />) + expect(screen.getByRole('alert').textContent).toContain(copy.sessionExpired) + await expect(`${screen.getByRole('alert').textContent}\n`).toMatchFileSnapshot(`./expected/expired-${copy === en ? 'en' : 'zh'}.txt`) +}) diff --git a/packages/client/ui-settings-account/tests/expected/expired-en.txt b/packages/client/ui-settings-account/tests/expected/expired-en.txt new file mode 100644 index 0000000000..4187486bfe --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/expired-en.txt @@ -0,0 +1 @@ +You have signed out of your account, please log in again. diff --git a/packages/client/ui-settings-account/tests/expected/expired-zh.txt b/packages/client/ui-settings-account/tests/expected/expired-zh.txt new file mode 100644 index 0000000000..7995f21137 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/expired-zh.txt @@ -0,0 +1 @@ +登录信息已失效,请重新登录 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 3147eeab54..3854ee9364 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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/client/ui-settings-models/README.md -README.md: 289a0ec0b3603590dddcab848a79235ca4579bcf -README.zh.md: 10c61a464d48933a692a265b5d5b0812bf79cdbb +README.md: 99b090dcdda887e75b9e6bee6eb73495d9235af1 +README.zh.md: e89be1ab2959daf2758209c376ea6468d7953ed7 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 289a0ec0b3..99b090dcdd 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -59,7 +59,7 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. -The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. Account availability follows the stored account credential. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 10c61a464d..e89be1ab29 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -59,7 +59,7 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 -Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用性取决于已保存的账号凭据。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 ----- diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index f693b5960d..34a20107e3 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -240,7 +240,7 @@ export class ModelsSettingsStore { s.error = null s.credentialError = credentialError s.writable = writable - s.rows = rows.map((row) => { + s.rows = rows.filter(row => row.entry.provider !== 'deepseek-account' || row.accountAvailable === true).map((row) => { if (row.entry.provider === 'deepseek-account') return row const named = row.apiKeyEnv === undefined ? undefined : credentials[row.apiKeyEnv] const derived = row.apiKeyEnv !== undefined ? undefined : credentials[deriveKeyRef(row.entry.provider)] diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 5a56062da4..85b2347122 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -350,8 +350,28 @@ it.each([false, true])('uses account availability without asking for an API key: }] }) }) const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() - const row = store.store.getSnapshot().rows[0]! - expect(row).toMatchObject({ accountAvailable, apiKeyEnv: undefined, credential: undefined }) - expect(providerUsable(row)).toBe(accountAvailable) + const rows = store.store.getSnapshot().rows + expect(rows).toHaveLength(accountAvailable ? 1 : 0) + if (accountAvailable) { + expect(rows[0]).toMatchObject({ accountAvailable: true, apiKeyEnv: undefined, credential: undefined }) + expect(providerUsable(rows[0]!)).toBe(true) + } expect(seenRefs).toEqual([]) }) + +it('removes the account row after sign-out and restores it after sign-in', async () => { + const overrides = { accountAvailable: true, providers: async () => ok({ providers: [{ + provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [], active: true, + }, ...DIRECTORY] }) } + const { ctx, mirror } = api(overrides) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) + await store.load() + expect(store.store.getSnapshot().rows[0]?.entry.provider).toBe('deepseek-account') + overrides.accountAvailable = false + await store.load() + expect(store.store.getSnapshot().rows.map(row => row.entry.provider)).not.toContain('deepseek-account') + expect(store.store.getSnapshot().rows).toHaveLength(DIRECTORY.length) + overrides.accountAvailable = true + await store.load() + expect(store.store.getSnapshot().rows[0]?.entry.provider).toBe('deepseek-account') +}) diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index b896927357..236da44ed1 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/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/credentials/deepseek-account-platform/README.md -README.md: 31172b1749428decebed41fac99e3fc34a70cf6d -README.zh.md: bdff2428021b7452865cb10d6774fff618c3b111 +README.md: 16cb23d805b15e7c2c0d3f6a0b71f52a35626f7a +README.zh.md: 63c2ec09f0ccae7ee7e3f6388974c5244676e939 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 31172b1749..16cb23d805 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,6 +13,8 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. The Desktop profile supplies `darwin` or `win32`; Host authorization, profile, balance, and logout requests then carry `x-client-platform: desktop-mac` or `desktop-win`, overriding that header in deployment configuration. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. Other profiles omit the automatic header. +Profile and balance HTTP 401 responses clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. + ## Summary Sign in through the system browser and keep the account credential in the existing local credential store. Local cancellation prevents late callbacks and exchange responses from signing the user in. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index bdff242802..63c2ec09f0 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,6 +13,8 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。Desktop profile 提供 `darwin` 或 `win32`,使 Host 的授权、资料、余额和退登请求携带 `x-client-platform: desktop-mac` 或 `desktop-win`,覆盖部署配置中的同名请求头。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头;其他 profile 不自动添加平台请求头。 +资料和余额接口返回 HTTP 401 时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 + ## 概述 通过系统浏览器登录,并将账号凭证保存在现有本地凭证存储中。本地取消会阻止迟到的回调和兑换响应使用户登录。 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index d77606bd7b..a7847d0ca8 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -1,7 +1,7 @@ /** Platform Web profile and wallet queries projected for account UI consumers. */ import { z } from 'zod' import type { AccountDetails, AccountProfile, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types' -import { PlatformAuthError, requestAccount } from './protocol.ts' +import { AccountUnauthorizedError, PlatformAuthError, requestAccount } from './protocol.ts' const user = z.object({ id: z.string().nullish(), @@ -48,10 +48,14 @@ const queries: { [K in keyof AccountDetails]: { * @param signal - request and credential lifetime. * @param headers - validated deployment headers for the configured origin. * @returns sanitized query outcome; failure never becomes a zero balance. + * @throws AccountUnauthorizedError when Platform rejects the stored token with HTTP 401. */ export async function readAccountDetail(field: K, origin: string, token: string, signal: AbortSignal, headers: Record): Promise { const query = queries[field] try { return query.parse(await requestAccount(origin, query.path, token, signal, headers)) } - catch { return { status: 'failed' } } + catch (error) { + if (error instanceof AccountUnauthorizedError) throw error + return { status: 'failed' } + } } diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 835b79d7da..c41ec18100 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -10,6 +10,7 @@ import { z } from 'zod' import { DeepSeekAccount, desktopClientHeaders, mergePlatformCookies, type PlatformSession, type AccountDetails, type AccountView, type SignInAttemptId, type SignInAttemptView } from '@deepseek-ai/dsh-deepseek-account' import { credentialKey } from '@deepseek-ai/dsh-credentials' import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization' +import { AccountUnauthorizedError } from './protocol.ts' import { profile, readAccountDetail } from './details.ts' import { revokeAccount, type LogoutRetryPolicy } from './logout.ts' import { PlatformAuthError, platformHeaders, platformOrigin, browserUrl, requestPlatform, initialization, exchange, loginOrigin } from './protocol.ts' @@ -98,6 +99,7 @@ export class PlatformAccount extends DeepSeekAccount { private detailsLifetime = new AbortController() private closed = false private removing: Promise | undefined + private signOutReason: 'expired' | undefined /** @param ctx - Host with authorization and credentials services. @param config - deployment options. */ constructor(ctx: Context, config: Config = {}) { @@ -170,6 +172,7 @@ export class PlatformAccount extends DeepSeekAccount { throw new PlatformAuthError('storage') } return { + ...(record === undefined && this.signOutReason !== undefined ? { signOutReason: this.signOutReason } : {}), status: record === undefined ? 'signed-out' : 'credential-stored', attempt: this.attempt?.view ?? null, links: { usageUrl: new URL('/usage', this.origin).href, topUpUrl: new URL('/top_up', this.origin).href }, } @@ -200,10 +203,34 @@ export class PlatformAccount extends DeepSeekAccount { delete this.attempt.initialProfile return initial as AccountDetails[K] } - const details = await readAccountDetail(field, this.origin, parsed.data.token, - AbortSignal.any([lifetime.signal, AbortSignal.timeout(this.requestTimeout)]), - { ...this.accountRequestHeaders, ...this.clientHeaders }) - return this.detailsLifetime !== lifetime ? null : details + try { + const details = await readAccountDetail(field, this.origin, parsed.data.token, + AbortSignal.any([lifetime.signal, AbortSignal.timeout(this.requestTimeout)]), + { ...this.accountRequestHeaders, ...this.clientHeaders }) + return this.detailsLifetime !== lifetime ? null : details + } catch (error) { + if (!(error instanceof AccountUnauthorizedError)) throw error + if (this.detailsLifetime === lifetime) await this.expireCredential(parsed.data.token, lifetime) + return null + } + } + + private async expireCredential(token: string, lifetime: AbortController): Promise { + this.removing ??= (async () => { + if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) + const record = await this.ctx.credentials.readRecord(KEY) + if (this.closed || this.detailsLifetime !== lifetime || record?.kind !== 'grant') return this.getState() + const current = grant.parse(record.payload) + if (current.token !== token || current.issuer !== this.origin) return this.getState() + this.signOutReason = 'expired' + try { await this.ctx.credentials.deleteRecord(KEY) } + catch (error) { this.signOutReason = undefined; throw error } + this.attempt = undefined + this.ctx.emit('deepseek-account/signed-out') + this.changed() + return this.getState() + })().finally(() => { this.removing = undefined }) + await this.removing } override async getPlatformSession(): Promise { @@ -241,6 +268,7 @@ export class PlatformAccount extends DeepSeekAccount { override async startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise { if (this.removing !== undefined) await this.removing + this.signOutReason = undefined const origin = loginOrigin(callbackOrigin) if (this.closed) throw new PlatformAuthError('protocol') if (this.attempt !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(this.attempt.view.phase)) { @@ -299,6 +327,7 @@ export class PlatformAccount extends DeepSeekAccount { } override signOut(): Promise { + this.signOutReason = undefined this.removing ??= (async () => { if (this.closed) throw new PlatformAuthError('protocol') if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index c437f67ac3..092518c4ad 100644 --- a/packages/credentials/deepseek-account-platform/src/protocol.ts +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -7,6 +7,11 @@ export class PlatformAuthError extends Error { constructor(readonly code: 'network' | 'protocol' | 'expired' | 'storage') { super(`account: ${code}`) } } +/** An authenticated Platform request was rejected with HTTP 401. */ +export class AccountUnauthorizedError extends PlatformAuthError { + constructor() { super('expired') } +} + /** * Accept HTTPS platform endpoints, or explicitly configured loopback development HTTP. * @param value - configured origin. @@ -132,6 +137,10 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign throw new PlatformAuthError('network') } console.info('[deepseek-account] response', { path, status: response.status }) + if (response.status === 401 && new Headers(init.headers).has('x-dsh-auth-token')) { + await response.body?.cancel() + throw new AccountUnauthorizedError() + } if (!response.ok || response.body === null) { await response.body?.cancel() throw new PlatformAuthError('network') diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index d04dc498ce..e690a5ccfc 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -45,6 +45,7 @@ async function fixture( let origin = '' let detailsHold = false let balanceHold = false + let detailStatus = 200 let profileFailed = false let summaryFailed = false let logoutFailed = false @@ -75,8 +76,10 @@ async function fixture( } if (req.method === 'GET') { detailRequests.push({ path: req.url!, authorization: req.headers['x-dsh-auth-token'] as string | undefined }) + const status = detailStatus detailsStarted.resolve(undefined) if (detailsHold || (balanceHold && req.url === '/api/v0/users/get_user_summary')) await release.promise + if (status !== 200) { res.writeHead(status).end(); return } const value = req.url === '/auth-api/v0/users/current' ? { id: 'test-user', token: 'never-copy-response-token', ...contact, id_profile: { name: 'Test Account', picture: null } } @@ -151,6 +154,7 @@ async function fixture( ctx, account, home, origin, callbackOrigin, wait, receivedHeaders, logoutHeaders, logoutCount: () => logoutCount, holdLogout: () => { logoutHold = true }, failLogout: (failed: boolean) => { logoutFailed = failed }, detailRequests, detailsStarted, + detailStatus: (status: number) => { detailStatus = status }, failProfile: (failed: boolean) => { profileFailed = failed }, holdBalance: () => { balanceHold = true }, holdDetails: () => { detailsHold = true }, failSummary: () => { summaryFailed = true }, @@ -801,3 +805,54 @@ it.each([undefined, [{ currency: 'EUR', balance: '1' }], [{ currency: 'CNY', bal expect(await f.account.getBalance()).toEqual({ status: 'failed' }) }, ) + +it.each(['profile', 'balance'] as const)('clears the rejected account grant after a %s HTTP 401', async (field) => { + const f = await fixture() + await storeAccount(f) + let signedOut = 0 + f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + f.detailStatus(401) + expect(await (field === 'profile' ? f.account.getProfile() : f.account.getBalance())).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired', attempt: null }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toBeUndefined() + expect(signedOut).toBe(1) +}) + +it('coalesces simultaneous unauthorized profile and balance responses', async () => { + const f = await fixture() + await storeAccount(f) + let signedOut = 0 + f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + f.detailStatus(401) + await Promise.all([f.account.getProfile(), f.account.getBalance()]) + expect(signedOut).toBe(1) + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired' }) +}) + +it.each([403, 500])('retains the account grant after HTTP %s', async (status) => { + const f = await fixture() + await storeAccount(f) + f.detailStatus(status) + expect(await f.account.getBalance()).toEqual({ status: 'failed' }) + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) +}) + +it('does not remove a replacement grant when an older request is rejected', async () => { + const f = await fixture() + await storeAccount(f) + f.detailStatus(401) + f.holdDetails() + const pending = f.account.getBalance() + try { + await f.detailsStarted.promise + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer: f.origin, token: 'replacement-grant' }, + })) + } finally { + f.release.resolve(undefined) + } + expect(await pending).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))) + .toMatchObject({ kind: 'grant', payload: { token: 'replacement-grant' } }) +}) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 4841809a90..c2ae5249f5 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/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/credentials/deepseek-account/README.md -README.md: 52bc8e15ee72282b79c0b8823b56476650cdf728 -README.zh.md: 427b4a22aaf1ed503d41e90d0db600d220ef931e +README.md: bfebbc1ca9fa7cdfedbfbef7b6ca5f696043752c +README.zh.md: dd1fe65c17a89a73132380d663743601dd0ec3d5 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 52bc8e15ee..bfebbc1ca9 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -11,6 +11,8 @@ getPlatformSession returns a Host-only origin/token snapshot for native Platform `desktopClientHeaders` maps the native `darwin` and `win32` platforms to the shared Desktop account and update-policy request header; `null` adds no header. +`AccountView.signOutReason` identifies a server-expired credential separately from explicit sign-out; it is absent while signed in. + ## Summary Account consumers read stored login state, start or cancel a browser login, and sign out without editing API keys. Host model consumers resolve account credentials only for the provider-configured inference origin. diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index 427b4a22aa..dd1fe65c17 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -11,6 +11,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token `desktopClientHeaders` 将原生平台 `darwin` 和 `win32` 映射为 Desktop 账号与更新策略请求共用的请求头;`null` 不添加请求头。 +`AccountView.signOutReason` 区分服务端判定凭据失效和主动退登;登录状态下不携带该字段。 + ## 概述 账号使用方可读取本地登录状态、发起或取消浏览器登录,并在保留 API Key 的情况下退出。Host 模型使用方仅能为提供者配置的推理来源解析账号凭证。 diff --git a/packages/credentials/deepseek-account/src/types.ts b/packages/credentials/deepseek-account/src/types.ts index f59c959567..7bc5f624f9 100644 --- a/packages/credentials/deepseek-account/src/types.ts +++ b/packages/credentials/deepseek-account/src/types.ts @@ -21,6 +21,8 @@ export interface AccountLinks { /** Stored-account presence is not a claim that the server has validated its token. */ export interface AccountView { readonly status: 'signed-out' | 'credential-stored' + /** Server rejection cleared the local credential. */ + readonly signOutReason?: 'expired' readonly links: AccountLinks readonly attempt: SignInAttemptView | null } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f05c4ed4f6..53b23dd9f8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4130,7 +4130,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AccountView', - declaration: 'export interface AccountView {\n readonly status: \'signed-out\' | \'credential-stored\';\n readonly links: AccountLinks;\n readonly attempt: SignInAttemptView | null;\n}', + declaration: 'export interface AccountView {\n readonly status: \'signed-out\' | \'credential-stored\';\n readonly signOutReason?: \'expired\';\n readonly links: AccountLinks;\n readonly attempt: SignInAttemptView | null;\n}', }, { name: 'AccountWallet', From bc8e8005fc4d17a2ec4046c7103256ac43878c00 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:19:29 +0800 Subject: [PATCH 08/30] docs: refresh module graph for account provider dependencies --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 32 +++++++++++++++++--------------- docs/module-graph.zh.md | 32 +++++++++++++++++--------------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6ae367c64a..19bba03247 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: bfe3e5faa7168b03848ac76d2dd2584d4aa31f5e -module-graph.zh.md: bfa0f83e14cbcf89599fa207ec919195128db49e +module-graph.md: 7ccaa6d9eb060fadf75f581c6837cb7d06bd9e61 +module-graph.zh.md: 0e2ff4f1ea5780f7adc11693d7a97c39b37b0c3b diff --git a/docs/module-graph.md b/docs/module-graph.md index bfe3e5faa7..7ccaa6d9eb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -460,8 +460,6 @@ flowchart TD pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web - pkg_api_account_controller --> pkg_deepseek_account - pkg_api_account_controller --> pkg_typert_protocol pkg_api_remotes --> pkg_scope pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment @@ -546,17 +544,6 @@ flowchart TD pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain pkg_workspace --> pkg_typert_protocol - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_account - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_fs - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -609,6 +596,18 @@ flowchart TD pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout + pkg_llm_deepseek --> pkg_agent + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_account + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -631,6 +630,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web + pkg_api_account_controller --> pkg_agent + pkg_api_account_controller --> pkg_deepseek_account + pkg_api_account_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -1475,7 +1477,6 @@ flowchart TD | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`api-account-controller`](../packages/api/account-controller) | `api` | [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1515,7 +1516,6 @@ flowchart TD | [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | @@ -1530,10 +1530,12 @@ flowchart TD | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`agent`](../packages/core/agent), [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index bfa0f83e14..0e2ff4f1ea 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -462,8 +462,6 @@ flowchart TD pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web - pkg_api_account_controller --> pkg_deepseek_account - pkg_api_account_controller --> pkg_typert_protocol pkg_api_remotes --> pkg_scope pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment @@ -548,17 +546,6 @@ flowchart TD pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain pkg_workspace --> pkg_typert_protocol - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_account - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_fs - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -611,6 +598,18 @@ flowchart TD pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout + pkg_llm_deepseek --> pkg_agent + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_account + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -633,6 +632,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web + pkg_api_account_controller --> pkg_agent + pkg_api_account_controller --> pkg_deepseek_account + pkg_api_account_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -1477,7 +1479,6 @@ flowchart TD | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`api-account-controller`](../packages/api/account-controller) | `api` | [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1517,7 +1518,6 @@ flowchart TD | [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | @@ -1532,10 +1532,12 @@ flowchart TD | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`agent`](../packages/core/agent), [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | From 43f107d164ce69a24c98f65b8835ce43982f7647 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:27:38 +0800 Subject: [PATCH 09/30] fix(account): expire grants rejected by Platform response code --- .../deepseek-account-platform/README.i18n.yaml | 4 ++-- .../deepseek-account-platform/README.md | 2 +- .../deepseek-account-platform/README.zh.md | 2 +- .../deepseek-account-platform/src/details.ts | 2 +- .../deepseek-account-platform/src/index.ts | 2 +- .../deepseek-account-platform/src/protocol.ts | 9 +++++++-- .../tests/account.spec.ts | 15 +++++++++++++++ .../tests/authorization-response.spec.ts | 18 ++++++++++++++++++ 8 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 4a5e95d0b8..1e0eb5ef40 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/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/credentials/deepseek-account-platform/README.md -README.md: 204689db355a988daa91f645c338421c50429193 -README.zh.md: 6ae778df799b6fff8e01bf46e284a537819b9412 +README.md: a7e9ef87ce59a01cef4a20b2d544a986d26a7d73 +README.zh.md: f64964af053f4ab601cdda3b90f4c15ffc360650 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 204689db35..a7e9ef87ce 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,7 +13,7 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. Every profile then sends `x-client-platform: web` on Host authorization, profile, balance, and logout requests; the Desktop profile supplies `darwin` or `win32`, replacing it with `x-client-platform: desktop-mac` or `desktop-win`. The provider owns that header, so deployment configuration cannot override it. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. -Profile and balance HTTP 401 responses clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. +Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. ## Summary diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 6ae778df79..f64964af05 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,7 +13,7 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。此时所有 profile 的 Host 授权、资料、余额和退登请求都携带 `x-client-platform: web`;Desktop profile 提供 `darwin` 或 `win32`,改为携带 `x-client-platform: desktop-mac` 或 `desktop-win`。该请求头由 provider 拥有,部署配置无法覆盖。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头。 -资料和余额接口返回 HTTP 401 时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 +资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 ## 概述 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index a7847d0ca8..a3638116b4 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -48,7 +48,7 @@ const queries: { [K in keyof AccountDetails]: { * @param signal - request and credential lifetime. * @param headers - validated deployment headers for the configured origin. * @returns sanitized query outcome; failure never becomes a zero balance. - * @throws AccountUnauthorizedError when Platform rejects the stored token with HTTP 401. + * @throws AccountUnauthorizedError when Platform rejects the stored token with HTTP 401 or response code 40003. */ export async function readAccountDetail(field: K, origin: string, token: string, signal: AbortSignal, headers: Record): Promise { diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index d0cc6f5d21..06896b701a 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -202,7 +202,7 @@ export class PlatformAccount extends DeepSeekAccount { { ...this.accountRequestHeaders, ...this.clientHeaders }) return this.detailsLifetime !== lifetime ? null : details } catch (_unauthorized) { - // readAccountDetail exposes only authenticated HTTP 401 failures. + // readAccountDetail exposes only authenticated credential rejection failures. if (this.detailsLifetime === lifetime) await this.expireCredential(stored.token, lifetime) return null } diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index 092518c4ad..c8e65107d8 100644 --- a/packages/credentials/deepseek-account-platform/src/protocol.ts +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -7,7 +7,7 @@ export class PlatformAuthError extends Error { constructor(readonly code: 'network' | 'protocol' | 'expired' | 'storage') { super(`account: ${code}`) } } -/** An authenticated Platform request was rejected with HTTP 401. */ +/** An authenticated Platform request was rejected with HTTP 401 or code 40003. */ export class AccountUnauthorizedError extends PlatformAuthError { constructor() { super('expired') } } @@ -162,6 +162,10 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } stage = 'parse-json' const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) + const authorization = z.object({ code: z.literal(40003) }).safeParse(payload) + if (authorization.success && new Headers(init.headers).has('x-dsh-auth-token')) { + throw new AccountUnauthorizedError() + } const codes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) if (codes.success) console.info('[deepseek-account] response codes', { path, code: codes.data.code, bizCode: codes.data.data?.biz_code, @@ -182,7 +186,8 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } return parsed.data.data.biz_data } catch (error) { - console.info('[deepseek-account] response rejected', { path, stage, errorCode: 'protocol' }) + console.info('[deepseek-account] response rejected', { path, stage, + errorCode: error instanceof PlatformAuthError ? error.code : 'protocol' }) if (error instanceof PlatformAuthError) throw error throw new PlatformAuthError('protocol') } finally { diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index a6e90657bd..805c8ed46d 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -49,6 +49,7 @@ async function fixture( let origin = '' let detailsHold = false let balanceHold = false + let detailCode = 0 let detailStatus = 200 let profileFailed = false let summaryFailed = false @@ -84,6 +85,7 @@ async function fixture( detailsStarted.resolve(undefined) if (detailsHold || (balanceHold && req.url === '/api/v0/users/get_user_summary')) await release.promise if (status !== 200) { res.writeHead(status).end(); return } + if (detailCode !== 0) { res.end(JSON.stringify({ code: detailCode, data: null })); return } const value = req.url === '/auth-api/v0/users/current' ? { id: 'test-user', token: 'never-copy-response-token', ...contact, id_profile: { name: 'Test Account', picture: null } } @@ -159,6 +161,7 @@ async function fixture( ctx, account, home, origin, callbackOrigin, wait, receivedHeaders, logoutHeaders, logoutCount: () => logoutCount, holdLogout: () => { logoutHold = true }, failLogout: (failed: boolean) => { logoutFailed = failed }, detailRequests, detailsStarted, + detailCode: (code: number) => { detailCode = code }, detailStatus: (status: number) => { detailStatus = status }, failProfile: (failed: boolean) => { profileFailed = failed }, holdBalance: () => { balanceHold = true }, @@ -883,6 +886,18 @@ it.each(['profile', 'balance'] as const)('clears the rejected account grant afte expect(signedOut).toBe(1) }) +it.each(['profile', 'balance'] as const)('clears the rejected account grant after a %s HTTP 200 with code 40003', async (field) => { + const f = await fixture() + await storeAccount(f) + let signedOut = 0 + f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + f.detailCode(40003) + expect(await (field === 'profile' ? f.account.getProfile() : f.account.getBalance())).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired', attempt: null }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toBeUndefined() + expect(signedOut).toBe(1) +}) + it('coalesces simultaneous unauthorized profile and balance responses', async () => { const f = await fixture() await storeAccount(f) diff --git a/packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts b/packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts new file mode 100644 index 0000000000..f28fd25928 --- /dev/null +++ b/packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts @@ -0,0 +1,18 @@ +import { expect, it, vi } from 'vitest' +import { AccountUnauthorizedError, requestAccount, requestPlatform } from '../src/protocol.ts' + +it.each([null, undefined])('recognizes invalid authorization without requiring business data (%s)', async (data) => { + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 40003, data }))) + try { + await expect(requestAccount('https://platform.deepseek.com', '/auth-api/v0/users/current', + 'fixture-token', new AbortController().signal, {})).rejects.toBeInstanceOf(AccountUnauthorizedError) + } finally { fetcher.mockRestore() } +}) + +it('does not classify an unauthenticated exchange failure as a rejected stored credential', async () => { + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 40003, data: null }))) + try { + await expect(requestPlatform('https://platform.deepseek.com', 'auth_exchange', {}, + new AbortController().signal, {})).rejects.toThrow('account: protocol') + } finally { fetcher.mockRestore() } +}) From 37d3a31e295bb93e4bf29e46b1b962c147075f95 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:44:26 +0800 Subject: [PATCH 10/30] fix(account): sign out on rejected inference tokens --- .../tests/platform-session.spec.ts | 1 + docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 7 ++ docs/subsystems/credentials.zh.md | 7 ++ .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 +- .../deepseek-account-platform/README.zh.md | 2 +- .../deepseek-account-platform/src/index.ts | 7 ++ .../tests/account.spec.ts | 36 ++++++ .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 + .../credentials/deepseek-account/README.zh.md | 2 + .../credentials/deepseek-account/src/index.ts | 6 + .../extensions/tool-cordis/src/api-catalog.ts | 6 + 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 | 119 ++++++++++-------- packages/llm/llm-deepseek/src/index.ts | 1 + packages/llm/llm-deepseek/src/transport.ts | 3 +- packages/llm/llm-deepseek/src/types.ts | 2 + .../llm/llm-deepseek/tests/adapter.spec.ts | 55 ++++++++ 22 files changed, 210 insertions(+), 68 deletions(-) diff --git a/apps/desktop-host/tests/platform-session.spec.ts b/apps/desktop-host/tests/platform-session.spec.ts index 3fee3c2d5e..7be4154979 100644 --- a/apps/desktop-host/tests/platform-session.spec.ts +++ b/apps/desktop-host/tests/platform-session.spec.ts @@ -20,6 +20,7 @@ class Account extends DeepSeekAccount { override async startSignIn() { return state } override async cancelSignIn() { return state } override async signOut() { return state } + override async rejectToken() {} override async resolveToken() { return undefined } override async getPlatformSession() { this.reading.resolve(undefined); return this.read() } override async *watch(signal: AbortSignal) { diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 186e3f2fb6..509edf1de2 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.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 docs/subsystems/credentials.md -credentials.md: bce883640da863b5d0cc7e5657ee2fcff9eec661 -credentials.zh.md: 58ed1e1d1df217f0ecdecc06a55e36447ad28d99 +credentials.md: f6d510d48ec92feb28cc3af8820a1e497fad2cdd +credentials.zh.md: 7040fe28cb930a0f2dce9a092124fa24e72cf767 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index bce883640d..f6d510d48e 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -320,6 +320,13 @@ abstract watch(signal: AbortSignal): AsyncIterable */ abstract resolveToken(url: string): Promise +/** + * Remove an inference-rejected token only while it still matches the stored login. + * @param token - token captured by the rejected inference request. + * @returns after matching credentials are removed and expiry state is published. + */ +abstract rejectToken(token: string): Promise + /** * Read credentials for the configured Platform origin, bound to their issuing environment. * @returns a Host-only snapshot, or null while signed out. diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 58ed1e1d1d..7040fe28cb 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -320,6 +320,13 @@ abstract watch(signal: AbortSignal): AsyncIterable */ abstract resolveToken(url: string): Promise +/** + * Remove an inference-rejected token only while it still matches the stored login. + * @param token - token captured by the rejected inference request. + * @returns after matching credentials are removed and expiry state is published. + */ +abstract rejectToken(token: string): Promise + /** * Read credentials for the configured Platform origin, bound to their issuing environment. * @returns a Host-only snapshot, or null while signed out. diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 1e0eb5ef40..69ffd59af5 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/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/credentials/deepseek-account-platform/README.md -README.md: a7e9ef87ce59a01cef4a20b2d544a986d26a7d73 -README.zh.md: f64964af053f4ab601cdda3b90f4c15ffc360650 +README.md: ecb4f9616bc9da0d25d5d8f70690efa983b35279 +README.zh.md: e384b982def75b07bb7290ca1bf70b1b4d144c70 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index a7e9ef87ce..ecb4f9616b 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,7 +13,7 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. Every profile then sends `x-client-platform: web` on Host authorization, profile, balance, and logout requests; the Desktop profile supplies `darwin` or `win32`, replacing it with `x-client-platform: desktop-mac` or `desktop-win`. The provider owns that header, so deployment configuration cannot override it. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. -Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. +Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. Host inference consumers can report a rejected request token through `rejectToken`; removal requires that it still matches the stored login. ## Summary diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index f64964af05..e384b982de 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,7 +13,7 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。此时所有 profile 的 Host 授权、资料、余额和退登请求都携带 `x-client-platform: web`;Desktop profile 提供 `darwin` 或 `win32`,改为携带 `x-client-platform: desktop-mac` 或 `desktop-win`。该请求头由 provider 拥有,部署配置无法覆盖。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头。 -资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 +资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。Host 推理调用方可以通过 `rejectToken` 报告被拒绝的请求 token;仅当它仍匹配当前登录凭据时才执行清除。 ## 概述 diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 06896b701a..3909be12e5 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -209,6 +209,13 @@ export class PlatformAccount extends DeepSeekAccount { } + override async rejectToken(token: string): Promise { + const lifetime = this.detailsLifetime + const stored = await this.readCurrentGrant(lifetime) + if (stored?.token !== token || this.detailsLifetime !== lifetime) return + await this.expireCredential(token, lifetime) + } + private async expireCredential(token: string, lifetime: AbortController): Promise { this.removing ??= (async () => { if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index 805c8ed46d..5d80023af5 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -1346,3 +1346,39 @@ it('ignores a 401 whose response cleanup overlaps a credential replacement', asy .toMatchObject({ kind: 'grant', payload: { token: 'new-login' } }) } finally { cleanup.mockRestore(); fetchResponse.mockRestore() } }) + +it('expires an inference-rejected token without another Platform request', async () => { + const f = await fixture() + await storeAccount(f) + const published: string[] = [] + f.ctx.on('deepseek-account/signed-out', () => { published.push('signed-out') }) + await f.account.rejectToken('test-platform-grant') + const state = await f.account.getState() + expect({ status: state.status, reason: state.signOutReason, published, detailRequests: f.detailRequests }) + .toMatchInlineSnapshot(` + { + "detailRequests": [], + "published": [ + "signed-out", + ], + "reason": "expired", + "status": "signed-out", + } + `) +}) + +it('retains a replacement login when inference rejects the previous token', async () => { + const f = await fixture() + await storeAccount(f) + await f.account.rejectToken('previous-platform-grant') + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))) + .toMatchObject({ kind: 'grant', payload: { token: 'test-platform-grant' } }) +}) + +it('ignores an inference rejection while already signed out', async () => { + const f = await fixture() + await f.account.rejectToken('previous-platform-grant') + expect(await f.account.getState()).toMatchObject({ status: 'signed-out' }) + expect((await f.account.getState()).signOutReason).toBeUndefined() +}) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 0289b83b50..04ef86deeb 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/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/credentials/deepseek-account/README.md -README.md: 85b4c9a7eca12358b425ccf7db092ae71ec98465 -README.zh.md: f29d1becdb258bfd6bc3b833d6e723f1f9e0e8f1 +README.md: eabed7ba74cc9995a46e42eb13eb238a06ec0359 +README.zh.md: 0f48d6286e8eec9d375439e052272e10f2871806 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 85b4c9a7ec..eabed7ba74 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -11,6 +11,8 @@ getPlatformSession returns a Host-only origin/token snapshot for native Platform `desktopClientHeaders` maps the native `darwin` and `win32` platforms to the shared Desktop account and update-policy request header; `null` adds no header. +`rejectToken` accepts a Host inference request’s rejected token and removes only the matching current login; a late rejection cannot clear a replacement credential. + `AccountView.signOutReason` identifies a server-expired credential separately from explicit sign-out; it is absent while signed in. ## Summary diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index f29d1becdb..0f48d6286e 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -11,6 +11,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token `desktopClientHeaders` 将原生平台 `darwin` 和 `win32` 映射为 Desktop 账号与更新策略请求共用的请求头;`null` 不添加请求头。 +`rejectToken` 接收 Host 推理请求被拒绝的 token,仅删除与它匹配的当前登录凭据;延迟返回的拒绝不能清除替换后的凭据。 + `AccountView.signOutReason` 区分服务端判定凭据失效和主动退登;登录状态下不携带该字段。 ## 概述 diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index 6ae86556c7..f876c749c8 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -75,6 +75,12 @@ export abstract class DeepSeekAccount extends Service { * @returns stored token, or undefined for other origins or a signed-out account. */ abstract resolveToken(url: string): Promise + /** + * Remove an inference-rejected token only while it still matches the stored login. + * @param token - token captured by the rejected inference request. + * @returns after matching credentials are removed and expiry state is published. + */ + abstract rejectToken(token: string): Promise /** * Read credentials for the configured Platform origin, bound to their issuing environment. * @returns a Host-only snapshot, or null while signed out. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5c4b0b05d8..6de43328f2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -901,6 +901,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'url', description: 'actual request destination or API base URL.' }], returns: 'stored token, or undefined for other origins or a signed-out account.', }, + { + signature: 'abstract rejectToken(token: string): Promise', + description: 'Remove an inference-rejected token only while it still matches the stored login.', + parameters: [{ name: 'token', description: 'token captured by the rejected inference request.' }], + returns: 'after matching credentials are removed and expiry state is published.', + }, { signature: 'abstract getPlatformSession(): Promise', description: 'Read credentials for the configured Platform origin, bound to their issuing environment.', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index d43739e1f4..765dd5a54a 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: 6a3e89788a5a23a56727fb41615898993939ef8c -README.zh.md: db364b968f33838d3f8360ec54b2cf929866be67 +README.md: 8019a8be1f0c8e1f00ae5573e350c3a7eaf1c721 +README.zh.md: 86855c49ebeb9fd3c3ca578d52eefe7b6e4c8f92 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 6a3e89788a..8019a8be1f 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. +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. ### Streaming with thinking and images diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index db364b968f..86855c49eb 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`。两种凭据模式均拒绝重定向。 +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 路由不会删除账号凭据。 ### 带 thinking 与图片的流式调用 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 03aa7b605b..41a2cf4a96 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -87,63 +87,72 @@ export class DeepSeekAdapter extends LlmAdapter { ) const key = await this.dependencies.resolveApiKey(connection) const accountToken = this.dependencies.accountCredential === true ? key : undefined - const files = new RequestFiles(this.files, { - baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, - }, - connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) - let inline = false - while (true) { - signal.throwIfAborted() - files.beginAttempt() - let fileIds: Awaited> | undefined - if (!inline) { - try { - fileIds = await prepareFileIds(messages, versions, files) - } catch (error) { - if (!(error instanceof FileResolutionFailure)) throw error - inline = true - continue + try { + const files = new RequestFiles(this.files, { + baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, + }, + connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) + let inline = false + while (true) { + signal.throwIfAborted() + files.beginAttempt() + let fileIds: Awaited> | undefined + if (!inline) { + try { + fileIds = await prepareFileIds(messages, versions, files) + } catch (error) { + if (!(error instanceof FileResolutionFailure)) throw error + inline = true + continue + } + } + const history = inline ? inlineImages(messages, versions, connection) : messages + const body = serialize(options, connection, history, versions, this.imageAccess, (reason) => { + this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) + }, fileIds) + const extensions = await prepareRequestExtensions(body as Readonly>, { + signal, + ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, + ...options.purpose === undefined ? {} : { purpose: options.purpose }, + }, this.dependencies.prepareExtensions) + signal.throwIfAborted() + const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { + method: 'POST', signal, body: extensions.payload, redirect: 'error', + headers: { + ...attributionHeaders(), + 'content-type': 'application/json', 'accept': 'text/event-stream', + ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, + 'anthropic-version': '2023-06-01', + ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, + 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), + ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, + ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, + }, + }) + if (!response.ok) { + const text = await response.text() + let raw: unknown + try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { + // HTTP status is authoritative when a gateway does not return JSON. + } + const detail = providerErrorDetail(raw) + 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) }) + } + await extensions.accept() + if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') + yield* translate(parseSse(response.body, activity), options.model) + return + } + } catch (error) { + if (accountToken !== undefined && error instanceof LlmError && error.code === 'ACCOUNT_TOKEN_INVALID') { + try { await this.dependencies.onInvalidAccountToken?.(accountToken) } catch (_credentialRemovalFailed) { + // Credential storage failure cannot replace the inference failure. } } - const history = inline ? inlineImages(messages, versions, connection) : messages - const body = serialize(options, connection, history, versions, this.imageAccess, (reason) => { - this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) - }, fileIds) - const extensions = await prepareRequestExtensions(body as Readonly>, { - signal, - ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, - ...options.purpose === undefined ? {} : { purpose: options.purpose }, - }, this.dependencies.prepareExtensions) - signal.throwIfAborted() - const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { - method: 'POST', signal, body: extensions.payload, redirect: 'error', - headers: { - ...attributionHeaders(), - 'content-type': 'application/json', 'accept': 'text/event-stream', - ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, - 'anthropic-version': '2023-06-01', - ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, - 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), - ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, - ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, - }, - }) - if (!response.ok) { - const text = await response.text() - let raw: unknown - try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { - // HTTP status is authoritative when a gateway does not return JSON. - } - const detail = providerErrorDetail(raw) - 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) }) - } - await extensions.accept() - if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') - yield* translate(parseSse(response.body, activity), options.model) - return + throw error } } } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 366f184e03..34a2581ece 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -89,6 +89,7 @@ export function apply(ctx: Context, config: Config): void { const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() const adapter = (accountCredential: boolean): DeepSeekAdapter => new DeepSeekAdapter({ accountCredential, + ...accountCredential ? { onInvalidAccountToken: async (token: string) => { await ctx.get('deepseekAccount')?.rejectToken(token) } } : {}, options, onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`) diff --git a/packages/llm/llm-deepseek/src/transport.ts b/packages/llm/llm-deepseek/src/transport.ts index 935e8855df..ddae610178 100644 --- a/packages/llm/llm-deepseek/src/transport.ts +++ b/packages/llm/llm-deepseek/src/transport.ts @@ -26,7 +26,8 @@ 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 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' + 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' 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/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 25bf19636b..8beb5030d5 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -86,6 +86,8 @@ export interface DeepSeekConnectionOptions { export interface DeepSeekAdapterOptions { /** Report unusable native Messages replay metadata without exposing content or signatures. */ onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void + /** Remove a server-rejected account token if it still matches the current login; failures preserve the inference error. */ + onInvalidAccountToken?: (token: string) => Promise /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 088985a3ad..b051aedda6 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -277,6 +277,61 @@ describe('Cordis provider composition', () => { return { ctx, http } } + it.each(['deepseek-account', 'deepseek-official'])('reports the rejected account token only for %s', async (provider) => { + 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)' } })) + }) + 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' } }, + }) + expect(rejectToken.mock.calls).toEqual(provider === 'deepseek-account' ? [['fixture-token']] : []) + }) + + it('finishes the active account turn when rejected credentials publish sign-out', async () => { + const { ctx } = await boot((response) => { + response.writeHead(401) + response.end(JSON.stringify({ error: { message: 'Authentication Fails (invalid dsh token)' } })) + }) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: async (_token: string): Promise => { ctx.emit('deepseek-account/signed-out') } } as DeepSeekAccount) + const agent = await ctx.agentLoop.create(SessionId('inference-account-expiry'), { provider: 'deepseek-account', model: MODEL }) + agent.followup(user('hello')) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ + reason: { kind: 'aborted', reason: { kind: 'hook', reason: 'deepseek-account/signed-out' } }, + }) + }) + + it('preserves the inference error when rejected credential removal fails', async () => { + const { ctx } = await boot((response) => { + response.writeHead(401) + response.end(JSON.stringify({ error: { message: 'Authentication Fails (invalid dsh token)' } })) + }) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: async (_token: string): Promise => { throw new Error('credential storage unavailable') } } as DeepSeekAccount) + expect((await chunks(ctx.llm.stream(options({ provider: 'deepseek-account' })))).at(-1)).toMatchObject({ + type: 'finish', reason: { kind: 'error', failure: { code: 'ACCOUNT_TOKEN_INVALID' } }, + }) + }) + + it('does not remove account credentials for a generic authentication error', async () => { + const { ctx } = await boot((response) => { + response.writeHead(401) + response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'API key is invalid' } })) + }) + 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: 'deepseek-account' })))).at(-1)).toMatchObject({ + type: 'finish', reason: { kind: 'error', failure: { code: 'AUTH' } }, + }) + expect(rejectToken).not.toHaveBeenCalled() + }) + it.each([ { model: MODEL, inHistory: false }, { model: MODEL, inHistory: true }, From 64154d2f68f4c43e7a7c0a2752bfdc063abd4dc1 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:49:54 +0800 Subject: [PATCH 11/30] test(models): declare available fixture models and update selection flows --- apps/web/tests/deepseek-messages-settings.e2e.ts | 9 ++++----- apps/web/tests/github-ready-review.e2e.ts | 1 + apps/web/tests/onboarding-deepseek-config.e2e.ts | 5 ++--- apps/web/tests/reasoning-preview.e2e.ts | 1 + apps/web/tests/schedule-after.e2e.ts | 3 +++ apps/web/tests/shipped-composition.e2e.ts | 2 ++ apps/web/tests/streaming-fence-highlight.e2e.ts | 1 + apps/web/tests/support.ts | 6 ++++-- packages/sdk/server/tests/server.spec.ts | 5 ++++- 9 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 8e5f2a9617..93aa400f57 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -84,17 +84,16 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('keeps a saved DeepSeek selection available after editing provider settings', async () => { + it('selects an available DeepSeek model after provider settings remove the default', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default')) await page.keyboard.press('Escape') await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) await page.reload({ waitUntil: 'load' }) const input = page.locator('[data-composer-input]').first() - await expect.poll(() => input.isEnabled()).toBe(true) - await page.getByRole('button', { name: /^选择模型/ }).click() - await page.getByRole('menuitem', { name: /模型/ }).click() + await expect.poll(() => input.getAttribute('contenteditable')).toBe('false') + await page.getByRole('button', { name: '请选择模型', exact: true }).click() await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click() - await expect.poll(() => input.isEnabled()).toBe(true) + await expect.poll(() => input.getAttribute('contenteditable')).toBe('true') await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-official') const settings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(settings).toContain('provider: deepseek-official') diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index 1477e2ea25..8bca2a746a 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -35,6 +35,7 @@ const REPLY = 'Review complete: no actionable findings.' /** Deterministic model response for the webhook-created Session. */ class ReviewAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] override async * stream(options: GenerateOptions): AsyncIterable { diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 30cae9db7b..d0e5ddd582 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -273,12 +273,11 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.keyboard.press('Escape') // A connected Workspace is what puts a live composer — and its model // trigger — on the page; the scaffold boots without one. - await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e') + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e', false) - const modelTrigger = page.getByRole('button', { name: /^选择模型/ }) + const modelTrigger = page.getByRole('button', { name: '请选择模型', exact: true }) await modelTrigger.waitFor({ timeout: 10_000 }) await modelTrigger.click() - await page.getByRole('menuitem', { name: /模型/ }).click() expect(await page.getByText('Configured Flash', { exact: true }).count()).toBe(0) await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 }) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts index 9571b51d1c..0d5911edde 100644 --- a/apps/web/tests/reasoning-preview.e2e.ts +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -14,6 +14,7 @@ const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail'] const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url)) class PausedReasoningAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] } readonly stages = DELTAS.map(text => ({ text, arrived: Promise.withResolvers(), diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 5dd7bf2ccb..9ce1560f7b 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -92,6 +92,7 @@ function textResponse(text: string): StreamChunk[] { /** Deterministic model seam that turns one due reminder into ordinary assistant prose. */ class ReminderAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] override async * stream(options: GenerateOptions): AsyncIterable { @@ -102,6 +103,7 @@ class ReminderAdapter extends LlmAdapter { /** Deterministic model seam for one multi-record fixed-rate batch. */ class EveryReminderAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] override async * stream(options: GenerateOptions): AsyncIterable { @@ -137,6 +139,7 @@ function localAt(epoch: number, timeZone: string): LocalAt { /** Dynamic model seam proving request-local browser context becomes an explicit At selector. */ class BrowserZoneAtAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] selectedAt: LocalAt | undefined scheduledAt: string | undefined diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index d1e6ecd83a..f6727eecaa 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -94,6 +94,7 @@ function textChunks(text: string): StreamChunk[] { /** Scripted same-route main model and reviewer for the shipped Auto pipeline. */ class ShippedAutoAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: AUTO_MODEL, name: AUTO_MODEL }] } readonly requests: GenerateOptions[] = [] constructor(private readonly targetPath: string) { @@ -196,6 +197,7 @@ function topLevelText(options: GenerateOptions): string { /** Same-route scripts for real one-shot, continuable, and cold-resumed children. */ class ShippedChildAutoAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: AUTO_MODEL, name: AUTO_MODEL }] } readonly requests: GenerateOptions[] = [] readonly reviews: ChildReviewObservation[] = [] private readonly children = new Map() diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts index 6d65cdca0d..bff5ff0f7e 100644 --- a/apps/web/tests/streaming-fence-highlight.e2e.ts +++ b/apps/web/tests/streaming-fence-highlight.e2e.ts @@ -30,6 +30,7 @@ const REPLY = `${OPEN_REPLY}\n\`\`\`` /** Deterministic model response held after each visible fence-growth frame. */ class StreamingFenceAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } private resolveFirstPaused!: () => void private resolveFirstContinuation!: () => void private resolveSecondPaused!: () => void diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 9945da66a4..83e5d25253 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -153,8 +153,9 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo * @param page - the browser page under test. * @param root - workspace parent directory. * @param name - directory created under `root` and connected. + * @param modelAvailable - require an editable composer when the selected model is available. */ -export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { +export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace', modelAvailable = true): Promise { mkdirSync(join(root, name), { recursive: true }) await page.getByRole('textbox', { name: '选择工作区' }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) @@ -164,7 +165,8 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]') + const editable = modelAvailable ? '[contenteditable="true"]' : '' + await page.locator(`[data-composer-input]${editable}[data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]`) .waitFor({ timeout: 15_000 }) } diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 3448c5dc87..eb74c12243 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -922,7 +922,10 @@ describe('HarnessSdkJsonRpcServer', () => { await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' })) .rejects.toThrow('no adapter registered for provider "private"') - expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.get('llm')?.listProviders()).toEqual([ + { id: 'deepseek-official', name: 'DeepSeek' }, + { id: 'deepseek-account', name: 'DeepSeek Account' }, + ]) await server.shutdown() } finally { await ctx.fiber.dispose() 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 12/30] 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) => {}) From f0f4bb73fccb4c01710e7ba5d36c6cf7c4882f32 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:58:55 +0800 Subject: [PATCH 13/30] fix(desktop): keep welcome back action on one line --- apps/desktop/renderer/welcome.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css index 3d5fc88460..068bcc36fd 100644 --- a/apps/desktop/renderer/welcome.css +++ b/apps/desktop/renderer/welcome.css @@ -192,6 +192,7 @@ body { top: calc(100% + 16px); left: 50%; transform: translateX(-50%); + white-space: nowrap; min-width: 58px; height: 28px; padding: 4px 10px; From 5a3c316401cd461f0234f64be39023702ccb4ea0 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:21:12 +0800 Subject: [PATCH 14/30] fix(desktop): deliver welcome expiry notice once across windows --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/client/WelcomePage.tsx | 12 ++++-- apps/desktop/src/main.ts | 17 ++++++-- apps/desktop/src/preload-welcome.ts | 3 +- apps/desktop/src/welcome-api.ts | 6 +++ apps/desktop/src/welcome-window.ts | 5 ++- apps/desktop/tests/preload.e2e.ts | 3 ++ .../tests/welcome-renderer.client.spec.tsx | 39 +++++++++++++++++-- apps/desktop/tests/welcome-startup.spec.ts | 14 ++++++- apps/desktop/tests/welcome-window.spec.ts | 9 ++++- 12 files changed, 101 insertions(+), 15 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 7dbf8777b4..a9b5df858a 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: cb10794ae6e9c5cbdc9e0f58acdefe8a252e35e1 -README.zh.md: 778fe9c6bc8df8a727d2a03a818a44f6fbb2e0f9 +README.md: 4ad377430d679fc505c480f2766b474b060a9f60 +README.zh.md: a36ebf77394e016336018d02f38b6688b2936f7f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cb10794ae6..4ad377430d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -409,6 +409,8 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. +When an expired account returns to Welcome, the main process retains a one-time notification until the renderer requests it over window-owned IPC. Reloading Welcome does not repeat the notice; manual sign-out and cold startup do not create it. + An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry. Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 778fe9c6bc..a36ebf7739 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -409,6 +409,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 +账号失效并返回 Welcome 时,主进程保留一次性通知,直到渲染器通过所属窗口的 IPC 领取。重新加载 Welcome 不会重复提示;主动退登和冷启动不会生成该通知。 + 登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。 内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。 diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index 054b9b11bf..b24618a0da 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -15,9 +15,7 @@ type Page = 'entry' | 'key' | 'account' */ export function Welcome({ api }: { api: WelcomeApi }) { const { messages: m } = api - const [expired, setExpired] = useState(false) const [expiryNotice, setExpiryNotice] = useState(false) - useEffect(() => { setExpiryNotice(expired) }, [expired]) const [page, setPage] = useState('entry') const pageRef = useRef('entry') const [attempt, setAttempt] = useState(null) @@ -53,9 +51,17 @@ export function Welcome({ api }: { api: WelcomeApi }) { mounted.current = true document.documentElement.lang = api.id document.title = m.welcomeTitle + const takeNotice = (): void => { + void api.takeNotice().then((notice) => { + if (mounted.current && notice === 'session-expired') setExpiryNotice(true) + }).catch((_closedChannel: unknown) => { + // A closed Welcome IPC channel must not interrupt the sign-in page. + }) + } + takeNotice() const stop = api.onAccountState((state) => { revision.current++ - setExpired(state.status === 'signed-out' && state.signOutReason === 'expired') + takeNotice() showAccount(state) }) return () => { mounted.current = false; stop() } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d9c55f44ae..45a3a4a8c4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -34,7 +34,7 @@ import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' import { openWelcomeWindow } from './welcome-window.ts' -import { WELCOME_IPC, needsWelcome } from './welcome-api.ts' +import { WELCOME_IPC, needsWelcome, type WelcomeNotice } from './welcome-api.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts' import { DesktopUpdateJournal } from './update-journal.ts' import { DesktopUpdatePreparationError } from './update-error.ts' @@ -307,6 +307,7 @@ async function main(): Promise { let stopAccount: (() => void) | undefined let openedAttempt: string | undefined let returnedAttempt: string | undefined + let pendingWelcomeNotice: WelcomeNotice | undefined let previousAccountStatus: string | undefined const assertProductSender = (event: IpcMainInvokeEvent): void => { assertDesktopSender(event, ['app']) @@ -362,8 +363,13 @@ async function main(): Promise { } if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace().catch(() => undefined) if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') { - void readWelcomeState().then((value) => { - if (!value.hasApiKey && !quitting) { enteredWorkspace = false; return showWelcome() } + void readWelcomeState().then(async (value) => { + if (needsWelcome(value) && !quitting) { + if (state.signOutReason === 'expired') pendingWelcomeNotice = 'session-expired' + enteredWorkspace = false + await showWelcome() + if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state) + } return undefined }).catch(() => undefined) } @@ -905,6 +911,11 @@ async function main(): Promise { } openingWelcome ??= (async () => { welcomeWindow = await openWelcomeWindow(locale, { + takeNotice: () => { + const notice = pendingWelcomeNotice + pendingWelcomeNotice = undefined + return Promise.resolve(notice) + }, startSignIn: async () => { if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') return welcomeBackend.account.start(locale.id) diff --git a/apps/desktop/src/preload-welcome.ts b/apps/desktop/src/preload-welcome.ts index 490a894394..e9ec1aa910 100644 --- a/apps/desktop/src/preload-welcome.ts +++ b/apps/desktop/src/preload-welcome.ts @@ -3,13 +3,14 @@ import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-acc import { contextBridge, ipcRenderer } from 'electron' import { resolveDesktopLocale } from './locale.ts' -import { WELCOME_IPC, type WelcomeApi, type WelcomeSaveResult } from './welcome-api.ts' +import { WELCOME_IPC, type WelcomeApi, type WelcomeNotice, type WelcomeSaveResult } from './welcome-api.ts' const prefix = '--dsh-welcome-locale=' const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice(prefix.length) if (locale === undefined) throw new Error('desktop welcome: missing window locale') const api: WelcomeApi = { ...resolveDesktopLocale(locale), + takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise, startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise, cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise, copySignInLink: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.copyLink, id) as Promise, diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts index 36dd85f17a..015296776a 100644 --- a/apps/desktop/src/welcome-api.ts +++ b/apps/desktop/src/welcome-api.ts @@ -11,13 +11,19 @@ export const WELCOME_IPC = { cancel: 'dsh-welcome:cancel', copyLink: 'dsh-welcome:copy-link', state: 'dsh-welcome:state', + takeNotice: 'dsh-welcome:take-notice', } as const /** Credential writes return a safe outcome without exposing Host diagnostics. */ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false } +/** One-time notification retained by the main process until Welcome receives it. */ +export type WelcomeNotice = 'session-expired' + /** Host-owned operations used by the welcome window. */ export interface WelcomeOperations { + /** @returns the pending notification, clearing it before another renderer can receive it. */ + takeNotice(): Promise /** @returns account state after starting a login attempt. */ startSignIn(): Promise /** @param id - attempt to cancel. @returns the settled state. */ diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts index 0edda21454..a32e1f1c56 100644 --- a/apps/desktop/src/welcome-window.ts +++ b/apps/desktop/src/welcome-window.ts @@ -63,7 +63,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco const disposeHandlers = (): void => { if (!active) return active = false - for (const channel of [WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink]) { + for (const channel of [ + WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink, + ]) { ipcMain.removeHandler(channel) } disposeActiveHandlers = undefined @@ -74,6 +76,7 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco throw new Error('desktop welcome: rejected action from an unowned frame') } } + ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() }) ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => { assertSender(event) if (typeof value !== 'string' || !/^[\x21-\x7e]+$/.test(value)) return { ok: false } diff --git a/apps/desktop/tests/preload.e2e.ts b/apps/desktop/tests/preload.e2e.ts index 1022b3a8b8..d18f475bc1 100644 --- a/apps/desktop/tests/preload.e2e.ts +++ b/apps/desktop/tests/preload.e2e.ts @@ -80,6 +80,9 @@ describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop pr expect(send).toHaveBeenCalledWith('dsh-desktop:locale-changed', 'zh') } else { expect(exposed.has('dshWelcome')).toBe(true) + const bridge = exposed.get('dshWelcome') as { takeNotice(): Promise } + void bridge.takeNotice() + expect(invoke).toHaveBeenCalledWith('dsh-welcome:take-notice') } }) }) diff --git a/apps/desktop/tests/welcome-renderer.client.spec.tsx b/apps/desktop/tests/welcome-renderer.client.spec.tsx index 0778b59805..0d3586ecfe 100644 --- a/apps/desktop/tests/welcome-renderer.client.spec.tsx +++ b/apps/desktop/tests/welcome-renderer.client.spec.tsx @@ -6,15 +6,16 @@ import { Welcome } from '../src/client/WelcomePage.tsx' import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveDesktopLocale } from '../src/locale.ts' import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' -import type { WelcomeSaveResult } from '../src/welcome-api.ts' +import type { WelcomeSaveResult, WelcomeNotice } from '../src/welcome-api.ts' const html = readFileSync(join(import.meta.dirname, '../renderer/welcome.html'), 'utf8') afterEach(cleanup) -function mount(language = 'zh-CN') { +function mount(language = 'zh-CN', takeNotice = vi.fn<() => Promise>().mockResolvedValue(undefined)) { cleanup() const stopAccount = vi.fn() const api = { + takeNotice, onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount), startSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })), cancelSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })), @@ -264,7 +265,9 @@ it.each(['copied', 'failed'] as const)('restores the copy action after %s feedba it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Welcome: %s', async (language) => { vi.useFakeTimers() try { - const view = mount(language) + const takeNotice = vi.fn<() => Promise>().mockResolvedValue(undefined).mockResolvedValueOnce('session-expired') + const view = mount(language, takeNotice) + await act(async () => {}) const publish = view.api.onAccountState.mock.calls[0]![0] const expired: AccountView = { status: 'signed-out', signOutReason: 'expired', attempt: null, links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } } @@ -277,5 +280,35 @@ it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Wel expect(screen.queryByRole('alert')).toBeNull() await act(async () => { publish(expired) }) expect(screen.queryByRole('alert')).toBeNull() + view.unmount() + mount(language, takeNotice) + await act(async () => {}) + expect(screen.queryByRole('alert')).toBeNull() } finally { cleanup(); vi.useRealTimers() } }) + +it('does not infer a notification from a retained expired account snapshot', async () => { + const view = mount() + await act(async () => { + view.api.onAccountState.mock.calls[0]![0]({ status: 'signed-out', signOutReason: 'expired', attempt: null, + links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } }) + }) + expect(screen.queryByRole('alert')).toBeNull() +}) + +it('keeps the entry usable when notification IPC fails', async () => { + const view = mount('en', vi.fn<() => Promise>().mockRejectedValue(new Error('closed'))) + await act(async () => {}) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(view.button('#api-key')) + expect(view.input.closest('[hidden]')).toBeNull() +}) + +it('ignores a notification received after its renderer unmounts', async () => { + const pending = Promise.withResolvers() + const view = mount('en', vi.fn<() => Promise>().mockReturnValue(pending.promise)) + view.unmount() + mount('en') + await act(async () => { pending.resolve('session-expired') }) + expect(screen.queryByRole('alert')).toBeNull() +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 20a5a441f4..737a3167be 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -131,7 +131,8 @@ vi.mock('../src/welcome-window.ts', () => ({ state.welcomeLocale = locale state.operations = operations await state.beforeWelcome() - return { once: vi.fn(), close: state.closeWelcome } + return { once: vi.fn(), close: state.closeWelcome, isDestroyed: () => false, + show: vi.fn(), focus: vi.fn(), webContents: { send: vi.fn() } } }, })) @@ -179,6 +180,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho expect(state.showWorkspace).not.toHaveBeenCalled() state.loadWorkspace.mockClear() expect(state.welcomeLocale).toMatchObject({ id: 'zh-CN' }) + expect(await state.operations!.takeNotice()).toBeUndefined() expect(state.dialogLocale!().id).toBe('zh-CN') const attemptId = 'login' as NonNullable['id'] const account: AccountView = { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' }, @@ -228,9 +230,19 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) await vi.advanceTimersByTimeAsync(0) expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount) + expect(await state.operations!.takeNotice()).toBeUndefined() state.hasApiKey = false state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) await vi.waitFor(() => { expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount + 1) }) + expect(await state.operations!.takeNotice()).toBe('session-expired') + expect(await state.operations!.takeNotice()).toBeUndefined() + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.advanceTimersByTimeAsync(0) + expect(await state.operations!.takeNotice()).toBeUndefined() + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null }) + await vi.advanceTimersByTimeAsync(0) + expect(await state.operations!.takeNotice()).toBeUndefined() }) diff --git a/apps/desktop/tests/welcome-window.spec.ts b/apps/desktop/tests/welcome-window.spec.ts index b357f8dcb5..5ab4a1c031 100644 --- a/apps/desktop/tests/welcome-window.spec.ts +++ b/apps/desktop/tests/welcome-window.spec.ts @@ -40,6 +40,7 @@ function createWindow() { beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() }) const operations = { + takeNotice: async () => undefined, startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }), cancelSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }), copySignInLink: async () => undefined, @@ -115,8 +116,14 @@ describe('desktop welcome window', () => { const saveApiKey = vi.fn(operations.saveApiKey) const skip = vi.fn(operations.skip) const copySignInLink = vi.fn(operations.copySignInLink) - await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, saveApiKey, skip, copySignInLink }) + const takeNotice = vi.fn(async () => 'session-expired' as const) + await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, saveApiKey, skip, copySignInLink, takeNotice }) const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const take = electron.handlers.get(WELCOME_IPC.takeNotice)! + await expect(take({ ...own, senderFrame: {} })).rejects.toThrow('unowned frame') + expect(takeNotice).not.toHaveBeenCalled() + expect(await take(own)).toBe('session-expired') + expect(takeNotice).toHaveBeenCalledOnce() const save = electron.handlers.get(WELCOME_IPC.saveApiKey)! await expect(save({ sender: {}, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame') await expect(save({ ...own, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame') From 10b6ffec3fb414a5673f88d350a5f019ce3dc396 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:00:13 +0800 Subject: [PATCH 15/30] fix(desktop): use regular weight for welcome API key heading --- apps/desktop/renderer/welcome.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css index 068bcc36fd..1e043dcee4 100644 --- a/apps/desktop/renderer/welcome.css +++ b/apps/desktop/renderer/welcome.css @@ -246,7 +246,7 @@ body { .key-heading h1 { margin: 0; font-size: 20px; - font-weight: 300; + font-weight: 400; line-height: 32px; letter-spacing: 0.01em; } From 4283f62d01a820ed04a10072b30a6ad2f3c1ff65 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:11:44 +0800 Subject: [PATCH 16/30] fix(desktop): leave sign-in activation to the completion page --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/main.ts | 9 +++++---- apps/desktop/tests/welcome-startup.spec.ts | 20 ++++++++++++++++++-- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 7ba0de6abb..d414b6d5c3 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 372ea4a0ac1ccb6ec7aeedd99899111f9b5801c7 -README.zh.md: b155745f68e9a3f706650f81309c8d1e7d179586 +README.md: f00608919c1a44441bffa57d2b601c12b47724a8 +README.zh.md: 76862075461d651a4d25235d57eb1855602ea90e diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 372ea4a0ac..f00608919c 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -411,7 +411,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i - Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog. - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate. -Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. +Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Successful browser sign-in switches Welcome to the workspace without activating the application; the completion page’s dsh://open link brings it to the foreground. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. When an expired account returns to Welcome, the main process retains a one-time notification until the renderer requests it over window-owned IPC. Reloading Welcome does not repeat the notice; manual sign-out and cold startup do not create it. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index b155745f68..7686207546 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -411,7 +411,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd - 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。 - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。 -登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 +登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。浏览器登录成功后,Welcome 切换到工作区但不激活应用;完成页的 dsh://open 链接负责将客户端置前。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 账号失效并返回 Welcome 时,主进程保留一次性通知,直到渲染器通过所属窗口的 IPC 领取。重新加载 Welcome 不会重复提示;主动退登和冷启动不会生成该通知。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 56b9d061e9..ca2ed4b204 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -395,7 +395,7 @@ async function main(): Promise { returnedAttempt = attempt.id focusPrimaryWindow() } - if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace().catch(() => undefined) + if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace({ activate: false }).catch(() => undefined) if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') { void readWelcomeState().then(async (value) => { if (needsWelcome(value) && !quitting) { @@ -924,19 +924,20 @@ async function main(): Promise { }) return window } - const enterWorkspace = async (): Promise => { + const enterWorkspace = async ({ activate = true }: { activate?: boolean } = {}): Promise => { if (quitting) return const window = mainWindow ?? createMainWindow() await navigateMain(applicationUrl) if (isQuitting() || recovery.active || window.isDestroyed()) return - window.show() + if (activate) window.show() + else window.showInactive() enteredWorkspace = true if (welcomeWindow !== undefined) { welcomeWindow.close() window.webContents.send(DESKTOP_IPC.enterWorkspace) } welcomeWindow = undefined - if (development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { + if (activate && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { window.webContents.openDevTools({ mode: 'detach' }) } } diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 859dc97e46..0d19f78585 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -23,6 +23,9 @@ const state = vi.hoisted(() => ({ stopHost: vi.fn<() => Promise>().mockResolvedValue(undefined), loadWorkspace: vi.fn<(url: string) => Promise>().mockResolvedValue(undefined), showWorkspace: vi.fn(), + showInactiveWorkspace: vi.fn(), + focusWorkspace: vi.fn(), + openDevTools: vi.fn(), closeWelcome: vi.fn(), welcomeLocale: undefined as DesktopLocale | undefined, preference: 'zh', @@ -64,16 +67,18 @@ vi.mock('electron', () => ({ BrowserWindow: class { constructor(options: BrowserWindowConstructorOptions) { state.windowOptions = options } private ready: (() => void) | undefined - webContents = { mainFrame: { url: 'dsh-app://app/' }, setWindowOpenHandler: vi.fn(), on: vi.fn(), once: vi.fn(), send: vi.fn(), openDevTools: vi.fn() } + webContents = { mainFrame: { url: 'dsh-app://app/' }, setWindowOpenHandler: vi.fn(), + on: vi.fn(), once: vi.fn(), send: vi.fn(), openDevTools: state.openDevTools } static getAllWindows() { return [] } once(name: string, callback: () => void) { if (name === 'ready-to-show') this.ready = callback; return this } on() { return this } isDestroyed() { return false } isMinimized() { return false } restore = vi.fn() - focus = vi.fn() + focus = state.focusWorkspace hide = vi.fn() show = state.showWorkspace + showInactive = state.showInactiveWorkspace async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() } }, net: { fetch: vi.fn() }, @@ -251,5 +256,16 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho state.accountListener!({ ...account, status: 'signed-out', attempt: null }) await vi.advanceTimersByTimeAsync(0) expect(await state.operations!.takeNotice()).toBeUndefined() + state.showWorkspace.mockClear() + state.focusWorkspace.mockClear() + vi.stubEnv('DSH_DESKTOP_OPEN_DEVTOOLS', '1') + state.accountListener!({ ...account, status: 'credential-stored', attempt: { id: attemptId, phase: 'succeeded' } }) + await vi.waitFor(() => { expect(state.showInactiveWorkspace).toHaveBeenCalledOnce() }) + expect(state.showWorkspace).not.toHaveBeenCalled() + expect(state.focusWorkspace).not.toHaveBeenCalled() + expect(state.openDevTools).not.toHaveBeenCalled() + state.appListeners.get('open-url')!({ preventDefault: vi.fn() }, 'dsh://open') + expect(state.showWorkspace).toHaveBeenCalledOnce() + expect(state.focusWorkspace).toHaveBeenCalledOnce() }) From c062c615dc0db26c7244d62ee37e60194a46e2b3 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:12:06 +0800 Subject: [PATCH 17/30] feat(account): align signed-out sidebar menu with design --- .../signed-out-menu.expected.md | 4 +++ apps/web/tests/onboarding-native.e2e.ts | 31 +++++++++++++++++-- .../ui-settings-account/README.i18n.yaml | 4 +-- packages/client/ui-settings-account/README.md | 4 +-- .../client/ui-settings-account/README.zh.md | 4 +-- .../src/client/AccountMenu.module.css | 29 +++++++++++++++++ .../src/client/AccountMenu.tsx | 16 ++++++---- .../ui-settings-account/src/client/locales.ts | 4 +-- .../tests/account.client.spec.tsx | 21 +++++++++++++ .../tests/expected/login-failed-en.txt | 2 +- .../tests/expected/menu-signed-out-en.txt | 1 + .../tests/expected/menu-signed-out-zh.txt | 1 + 12 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 apps/web/tests/expected/onboarding-native/signed-out-menu.expected.md create mode 100644 packages/client/ui-settings-account/tests/expected/menu-signed-out-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/menu-signed-out-zh.txt diff --git a/apps/web/tests/expected/onboarding-native/signed-out-menu.expected.md b/apps/web/tests/expected/onboarding-native/signed-out-menu.expected.md new file mode 100644 index 0000000000..6fe7a0d67b --- /dev/null +++ b/apps/web/tests/expected/onboarding-native/signed-out-menu.expected.md @@ -0,0 +1,4 @@ +- menu: + - menuitem "设置" + - menuitem "联系我们" + - menuitem "登录" diff --git a/apps/web/tests/onboarding-native.e2e.ts b/apps/web/tests/onboarding-native.e2e.ts index c899288606..c59c7f238a 100644 --- a/apps/web/tests/onboarding-native.e2e.ts +++ b/apps/web/tests/onboarding-native.e2e.ts @@ -11,6 +11,7 @@ import { import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-native', import.meta.url)) +const SIGNED_OUT_MENU_EXPECTED = join(SNAPSHOT_DIR, 'signed-out-menu.expected.md') const MODE = webSnapshotMode() describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credential onboarding (desktop marker: %s)', (desktop) => { @@ -53,8 +54,32 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti acknowledgeReloadConnectionLoss(tripwire, warningsBefore) } const accountMenu = page.getByRole('button', { name: '账号菜单', exact: true }) - if (desktop) await accountMenu.waitFor() - else { + if (desktop) { + await accountMenu.waitFor() + expect(await accountMenu.textContent()).toBe('更多') + const triggerBox = (await accountMenu.boundingBox())! + expect(Math.abs(triggerBox.height - 32)).toBeLessThan(1) + await accountMenu.click() + const menu = page.getByRole('menu') + await menu.waitFor() + expect(await menu.getByRole('menuitem').allTextContents()).toEqual(['设置', '联系我们', '登录']) + const menuBox = (await menu.boundingBox())! + expect(Math.abs(menuBox.width - 124)).toBeLessThan(1) + expect(Math.abs(menuBox.height - 128)).toBeLessThan(1) + expect(Math.abs(triggerBox.y - (menuBox.y + menuBox.height) - 4)).toBeLessThan(1) + for (const row of await menu.getByRole('menuitem').all()) { + const rowBox = (await row.boundingBox())! + expect(Math.abs(rowBox.height - 40)).toBeLessThan(1) + expect(Math.abs(menuBox.width - rowBox.width - 8)).toBeLessThan(1) + const glyph = (await row.locator('svg').first().boundingBox())! + expect(Math.abs(glyph.width - 16)).toBeLessThan(1) + expect(Math.abs(glyph.height - 16)).toBeLessThan(1) + } + const menuAria = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SIGNED_OUT_MENU_EXPECTED, menuAria, MODE) + await page.keyboard.press('Escape') + await menu.waitFor({ state: 'detached' }) + } else { await page.getByRole('button', { name: '设置', exact: true }).waitFor() expect(await accountMenu.count()).toBe(0) expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0) @@ -72,6 +97,6 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti } expect(tripwire.warnings).toEqual([]) expect(tripwire.pageErrors).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['models.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['models.expected.md', 'signed-out-menu.expected.md']) }) }) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 5b10d61e19..d2992bfd87 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/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/client/ui-settings-account/README.md -README.md: 2eb0f56daec2ad66c1407e397e30a872bda8c52e -README.zh.md: f1c1a5b66327d9dee85536afb737efd2afa23acb +README.md: 55a747896f5c115d08ebacea6046b4de711a530c +README.zh.md: 582a1b21113631305eb38bd02a140479704d365d diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 2eb0f56dae..55a747896f 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -24,7 +24,7 @@ Desktop Account settings display DeepSeek login state and offer browser authoriz The client activates only inside Desktop, identified by its preload bridge. Plain Web clients keep the standard Settings launcher and API-key onboarding without account login, account settings, or an account-state subscription. -The sidebar and Account settings display the profile avatar as a circular image, with the account icon as fallback when the URL is absent or the image fails to load. The collapsed sidebar centers the avatar in a 36 × 36 px button. +The sidebar and Account settings display the profile avatar as a circular image, with the account icon as fallback when the URL is absent or the image fails to load. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a More row instead, leading with the ellipsis glyph. The section registers through settings.section and uses the account Remote namespace. The stream survives carrier reconnects through the shared Remote supervisor. The page renders feature-owned English and Chinese copy and keeps API keys separate from account state. @@ -34,7 +34,7 @@ Desktop usage and top-up actions open an isolated native Platform view below a 4 The account menu's Feedback entry opens the Feishu questionnaire in the system browser. It supplies the available build version, UI locale and physical screen resolution as prefill_* parameters, with hide_*=1 for every context field; the account UID, tokens and contact details are excluded. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form. contactSource defaults to empty until the questionnaire supports a Harness source option; OS and device fields remain unfilled, matching the Web implementation. -The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, and row typography; feature styles only size its launcher. +The sidebar account menu uses the shared Menu surface and backdrop blur. The signed-in menu keeps the shared row typography; the signed-out menu carries its own wider rows and its Contact us copy for the Feedback entry. The account card’s More account information link opens `https://platform.deepseek.com` in the system browser. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index f1c1a5b663..582a1b2111 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -24,7 +24,7 @@ Desktop 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览 客户端仅在具有 preload 桥接的 Desktop 内启用。普通 Web 客户端保留标准设置入口和 API Key 引导,不显示账号登录和账号设置,也不订阅账号状态。 -侧边栏和账号设置以圆形图片显示账号头像;头像 URL 缺失或图片加载失败时使用账号图标兜底。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。 +侧边栏和账号设置以圆形图片显示账号头像;头像 URL 缺失或图片加载失败时使用账号图标兜底。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为「更多」行,以三点图标开头。 该页面通过 settings.section 注册,并使用 account Remote 命名空间。共享 Remote 管理器负责连接恢复后的流重连。页面使用功能自有的中英文文案,将 API Key 与账号状态分开。 @@ -34,7 +34,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。链接通过 prefill_* 参数填写已有的构建版本、界面语言和屏幕物理分辨率,并为所有上下文字段设置 hide_*=1;不传账号 UID、token 或联系方式。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷。问卷支持 Harness 来源选项前,contactSource 默认为空;OS 和设备字段沿用 Web 实现,保持未填写。 -侧边栏账号菜单使用公共 Menu 的表面、背景模糊、间距和菜单项排版;功能样式仅设置入口布局。 +侧边栏账号菜单使用公共 Menu 的表面和背景模糊。已登录菜单沿用公共菜单项排版;未登录菜单使用自己的较宽菜单项,并将“意见反馈”入口的文案改为“联系我们”。 账号卡片的“更多账号信息”链接通过系统浏览器打开 `https://platform.deepseek.com`。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.module.css b/packages/client/ui-settings-account/src/client/AccountMenu.module.css index 71a1f38572..33dc01d4df 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.module.css +++ b/packages/client/ui-settings-account/src/client/AccountMenu.module.css @@ -6,3 +6,32 @@ .avatar { display: flex; align-items: center; justify-content: center; flex: none; width: 32px; height: 32px; border-radius: 16px; background: var(--dsw-alias-bg-skeleton); color: var(--dsw-alias-label-tertiary); } .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .error { color: var(--dsw-alias-state-error-primary); font-size: 12px; } + +/* Signed-out launcher: a 32px More row carrying the 14px ellipsis. */ +.trigger[data-signed-out='true']:not([data-collapsed='true']) { height: 32px; padding: 6px 2px 6px 6px; line-height: 20px; } + +/* Signed-out menu card and rows; listClassName reaches the portaled card. */ +.signedOutMenu[role='menu'] { + min-width: 124px; + max-width: 280px; + padding: 4px; + border-radius: 12px; + --dsw-elevation-stroke-color: var(--dsw-alias-border-inverted); +} + +.signedOutMenu [role='menuitem'] { + min-height: 40px; + gap: 8px; + padding: 8px 10px; + border-radius: 10px; + font-size: 14px; + line-height: 22px; +} + +.signedOutMenu [role='menuitem'] > span:first-child:not(:last-child) { + width: 16px; + height: 16px; + color: var(--dsw-alias-label-primary); +} + +.signedOutMenu [role='menuitem'] > span:first-child:not(:last-child) svg { width: 16px; height: 16px; } diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 2ad41b76a3..641716e16b 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -1,6 +1,8 @@ /** Sidebar account launcher and locally authoritative sign-out action. */ import { useEffect, useState } from 'react' -import { Toast, Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Toast, Menu, IconEllipsisOutlineMedium, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AccountSectionInjected } from './AccountSection.tsx' import { SignOutDialog } from './SignOutDialog.tsx' @@ -41,15 +43,17 @@ export function AccountMenu({ const beginSignIn = (): void => { setOpen(false); void start().catch(() => undefined) } return
{expiryNotice && { setExpiryNotice(false) }} />} - { setOpen(value => !value) }}> - - {wide && {signedIn ? label : t('signedOut')}} + {signedIn + ? + : } + {wide && {signedIn ? label : t('more')}} } items={[ { id: 'settings', label: t('settings'), icon: }, - { id: 'contact', label: t('contactUs'), icon: }, + { id: 'contact', label: signedIn ? t('contactUs') : t('contactUsSignedOut'), icon: }, ...(signedIn ? [{ id: 'signout', label: t('signOut'), icon: , disabled: busy }] : [{ id: 'signin', label: t('signIn'), icon: }]), ]} diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index 298475e62a..22a61c95a8 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -9,7 +9,7 @@ export const en = { failureTitle: 'Could not sign in', platformFailed: 'Could not complete the operation. Try again.', platformRetry: 'Retry', loading: 'Loading…', backToHarness: 'Back to DeepSeek Harness', - settings: 'Settings', contactUs: 'Feedback', menu: 'Account menu', + settings: 'Settings', contactUs: 'Feedback', contactUsSignedOut: 'Contact us', menu: 'Account menu', nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in', signIn: 'Sign in', signOut: 'Sign out', signOutUnknownDescription: 'Could not check running tasks. Signing out may interrupt tasks using this account. Sign out now?', @@ -38,7 +38,7 @@ export const zh: Record = { failureTitle: '登录失败', platformFailed: '操作未完成,请重试', platformRetry: '重试', loading: '加载中…', backToHarness: '返回 DeepSeek Harness', - settings: '设置', contactUs: '意见反馈', menu: '账号菜单', + settings: '设置', contactUs: '意见反馈', contactUsSignedOut: '联系我们', menu: '账号菜单', nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录', signIn: '登录', signOut: '退出登录', signOutUnknownDescription: '暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index ea453dd99d..58fbbd01f4 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -95,6 +95,27 @@ it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running expect(screen.queryByRole('menu')).toBeNull() }) +it.each([en, zh])('offers settings, contact and sign-in from the signed-out account menu', async (copy) => { + const openSettings = vi.fn() + const operations = operationsOf({ status: 'signed-out', attempt: null }) + const { AccountMenu } = await import('../src/client/AccountMenu.tsx') + render( selector(operations.hooks.account.getSnapshot())} + useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings} + t={key => key in copy ? copy[key as AccountKey] : key} />) + const trigger = screen.getByRole('button', { name: copy.menu }) + expect(trigger.textContent).toBe(copy.more) + expect(trigger.querySelector('svg')).not.toBeNull() + fireEvent.click(trigger) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([copy.settings, copy.contactUsSignedOut, copy.signIn]) + await expect(`${screen.getByRole('menu').textContent}\n`).toMatchFileSnapshot(`./expected/menu-signed-out-${copy === en ? 'en' : 'zh'}.txt`) + fireEvent.click(screen.getByRole('menuitem', { name: copy.settings })) + expect(openSettings).toHaveBeenCalledOnce() + fireEvent.click(screen.getByRole('button', { name: copy.menu })) + fireEvent.click(screen.getByRole('menuitem', { name: copy.contactUsSignedOut })) + expect(operations.contactUs).toHaveBeenCalledOnce() +}) + it('reports a failed start in the login dialog, not as a sidebar alert', async () => { const operations = mount({ status: 'signed-out', attempt: null }) cleanup() diff --git a/packages/client/ui-settings-account/tests/expected/login-failed-en.txt b/packages/client/ui-settings-account/tests/expected/login-failed-en.txt index bc8b5ad67b..3d89b41c4f 100644 --- a/packages/client/ui-settings-account/tests/expected/login-failed-en.txt +++ b/packages/client/ui-settings-account/tests/expected/login-failed-en.txt @@ -1 +1 @@ -Not signed inCould not sign inCould not complete the operation. Try again.Add API KeySign in again +MoreCould not sign inCould not complete the operation. Try again.Add API KeySign in again diff --git a/packages/client/ui-settings-account/tests/expected/menu-signed-out-en.txt b/packages/client/ui-settings-account/tests/expected/menu-signed-out-en.txt new file mode 100644 index 0000000000..9875291a51 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/menu-signed-out-en.txt @@ -0,0 +1 @@ +SettingsContact usSign in diff --git a/packages/client/ui-settings-account/tests/expected/menu-signed-out-zh.txt b/packages/client/ui-settings-account/tests/expected/menu-signed-out-zh.txt new file mode 100644 index 0000000000..fa5c8d7512 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/menu-signed-out-zh.txt @@ -0,0 +1 @@ +设置联系我们登录 From 40da69ff7510ae7c642285c4a3665c92743d8291 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:43:47 +0800 Subject: [PATCH 18/30] refactor(account): isolate provider authentication and preserve model selections --- ...6-09-17-account-provider-signout.i18n.yaml | 4 +- .../2026-09-17-account-provider-signout.md | 10 +- .../2026-09-17-account-provider-signout.zh.md | 10 +- apps/cli/composition.md | 7 +- apps/cli/package.json | 2 + apps/cli/tests/built-bin.e2e.ts | 2 +- apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 1 - .../tests/fixtures/control-surface/cordis.yml | 2 +- .../tests/fixtures/image-offload.cordis.yml | 2 +- .../headless/credentials-snapshot.patch.yml | 2 +- .../tests/profiles/headless/tests/harness.ts | 2 +- .../tests/profiles/headless/tests/ptc.e2e.ts | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 103 ++++---------- docs/config-catalog.zh.md | 103 ++++---------- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 37 ++--- docs/event-producer-consumer.zh.md | 37 ++--- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 4 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 4 - docs/subsystems/core.zh.md | 4 - docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 15 ++ docs/subsystems/credentials.zh.md | 15 ++ docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 4 +- docs/subsystems/session.zh.md | 4 +- .../api/account-controller/README.i18n.yaml | 4 +- packages/api/account-controller/README.md | 2 +- packages/api/account-controller/README.zh.md | 2 +- packages/api/account-controller/src/index.ts | 8 +- .../tests/signout-impact.spec.ts | 17 +-- .../api/job-controller/tests/host-harness.ts | 1 - .../job-controller/tests/observe.host.spec.ts | 1 - packages/api/remotes/package.json | 3 +- packages/api/remotes/src/remote-events.ts | 2 + packages/api/remotes/tsconfig.client.json | 3 + packages/api/remotes/tsconfig.host.json | 3 + .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/package.json | 7 + .../api/session-controller/src/catalog.ts | 28 ++++ .../api/session-controller/src/commands.ts | 3 - packages/api/session-controller/src/index.ts | 13 +- packages/api/session-controller/src/types.ts | 1 + .../tests/archived-session-gate.host.spec.ts | 1 - .../tests/session-models.host.spec.ts | 97 ++++++++++--- packages/bundle/base/cordis.patch.yml | 5 +- packages/bundle/base/package.json | 3 +- .../bundle/headless/tests/headless.spec.ts | 1 - packages/bundle/sdk-minimal/cordis.patch.yml | 2 +- packages/bundle/sdk-minimal/package.json | 2 +- .../sdk-minimal/tests/sdk-minimal.spec.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/skeleton/ConversationContent.tsx | 4 +- .../tests/skeleton.client.spec.tsx | 7 +- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 9 +- .../client/ui-model-selection/README.zh.md | 9 +- .../src/client/directory.ts | 10 +- .../ui-model-selection/src/client/index.ts | 4 +- .../ui-model-selection/src/client/locales.ts | 2 - .../ui-model-selection/src/client/service.ts | 26 +--- .../tests/browser-plugin.client.spec.ts | 95 ++----------- .../tests/model-select.client.spec.tsx | 31 ++++- .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 4 +- .../client/ui-settings-account/README.zh.md | 4 +- .../src/client/AccountMenu.tsx | 6 +- .../src/client/AccountSection.tsx | 5 + .../ui-settings-account/src/client/index.ts | 1 + .../ui-settings-account/src/client/locales.ts | 2 + .../tests/account.client.spec.tsx | 22 +++ .../context/agent-instructions/package.json | 1 + .../tests/agent-instructions.e2e.ts | 2 +- .../time-context/tests/time-context.spec.ts | 1 - .../tmux-context/tests/tmux-context.spec.ts | 1 - .../core/agent-default-model/README.i18n.yaml | 4 +- packages/core/agent-default-model/README.md | 2 +- .../core/agent-default-model/README.zh.md | 2 +- .../core/agent-default-model/src/index.ts | 4 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 1 - packages/core/agent-loop/README.zh.md | 1 - packages/core/agent-loop/package.json | 1 + packages/core/agent-loop/src/agent.ts | 8 +- packages/core/agent-loop/tests/inbox.spec.ts | 1 - .../agent-loop/tests/request-cache.e2e.ts | 2 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 1 - packages/core/agent/README.zh.md | 1 - packages/core/agent/src/runtime-types.ts | 2 - packages/core/agent/tests/agent.spec.ts | 1 - .../agent/tests/archive-admission.spec.ts | 1 - .../deepseek-account-platform/src/index.ts | 3 +- .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 4 +- .../credentials/deepseek-account/README.zh.md | 4 +- .../credentials/deepseek-account/package.json | 13 +- .../deepseek-account/src/account-tasks.ts | 35 +++++ .../credentials/deepseek-account/src/index.ts | 1 + .../credentials/deepseek-account/src/types.ts | 10 ++ .../tests/account-tasks.spec.ts | 42 ++++++ .../deepseek-account/tsconfig.json | 6 + .../tool-present/tests/present.spec.ts | 1 - .../experimental/auto-review/package.json | 1 + .../auto-review/tests/auto-review.e2e.ts | 2 +- .../tests/resources.spec.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 12 +- .../tests/command-feedback.spec.ts | 1 - .../tests/loader-composition.spec.ts | 1 - packages/fs/tool-fs/package.json | 1 + packages/fs/tool-fs/tests/harness.ts | 2 +- .../tests/tools.spec.ts | 1 - .../command-goal/tests/command-goal.spec.ts | 1 - packages/goal/goal/tests/goal.spec.ts | 1 - packages/goal/goal/tests/projection.spec.ts | 1 - .../goal/tool-goal/tests/tool-goal.spec.ts | 1 - packages/jobs/jobs-local/tests/events.spec.ts | 1 - packages/jobs/jobs-local/tests/jobs.spec.ts | 1 - packages/llm/README.i18n.yaml | 4 +- packages/llm/README.md | 4 +- packages/llm/README.zh.md | 4 +- .../llm/llm-deepseek-account/README.i18n.yaml | 6 + packages/llm/llm-deepseek-account/README.md | 75 ++++++++++ .../llm/llm-deepseek-account/README.zh.md | 75 ++++++++++ .../llm/llm-deepseek-account/package.json | 54 ++++++++ .../llm/llm-deepseek-account/src/config.ts | 6 + .../llm/llm-deepseek-account/src/index.ts | 47 +++++++ .../tests/provider.spec.ts | 44 ++++++ .../llm/llm-deepseek-account/tsconfig.json | 30 ++++ .../llm/llm-deepseek-api-key/README.i18n.yaml | 6 + packages/llm/llm-deepseek-api-key/README.md | 76 ++++++++++ .../llm/llm-deepseek-api-key/README.zh.md | 76 ++++++++++ .../llm/llm-deepseek-api-key/package.json | 53 +++++++ .../llm/llm-deepseek-api-key/src/config.ts | 39 ++++++ .../llm/llm-deepseek-api-key/src/index.ts | 51 +++++++ .../tests/discovery.spec.ts | 18 +++ .../llm/llm-deepseek-api-key/tsconfig.json | 33 +++++ packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 19 ++- packages/llm/llm-deepseek/README.zh.md | 19 ++- packages/llm/llm-deepseek/package.json | 13 +- packages/llm/llm-deepseek/src/adapter.ts | 32 ++--- packages/llm/llm-deepseek/src/config.ts | 36 ++--- packages/llm/llm-deepseek/src/host.ts | 46 +++++++ packages/llm/llm-deepseek/src/index.ts | 121 +--------------- packages/llm/llm-deepseek/src/types.ts | 24 ++-- .../tests/account-routing.spec.ts | 70 ++++++++-- .../llm/llm-deepseek/tests/adapter.e2e.ts | 19 +-- .../llm/llm-deepseek/tests/adapter.spec.ts | 30 ++-- .../llm-deepseek/tests/dynamic-config.spec.ts | 6 +- .../llm/llm-deepseek/tests/egress.spec.ts | 2 +- .../llm/llm-deepseek/tests/extensions.spec.ts | 2 +- .../llm-deepseek/tests/fixtures/cordis.yml | 5 +- .../tests/loader-composition.spec.ts | 8 +- .../llm/llm-deepseek/tests/model-info.spec.ts | 9 ++ .../llm-deepseek/tests/prepared-call.spec.ts | 5 +- .../llm/llm-deepseek/tests/runtime.e2e.ts | 19 +-- .../llm/llm-deepseek/tests/runtime.spec.ts | 49 +++---- packages/llm/llm-deepseek/tsconfig.json | 9 -- packages/llm/llm-pi-ai/package.json | 1 + packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 2 +- packages/llm/llm-retry/README.i18n.yaml | 4 +- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/README.zh.md | 2 +- packages/llm/llm-retry/package.json | 1 + .../tests/transport-recovery.spec.ts | 2 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- .../references/packages.md | 3 +- .../schedule/schedule/tests/runtime.spec.ts | 1 - .../schedule/schedule/tests/tools.spec.ts | 1 - packages/sdk/server/package.json | 4 +- packages/sdk/server/src/server.ts | 2 +- packages/sdk/server/tests/server.spec.ts | 3 +- packages/sdk/server/tsconfig.json | 2 +- .../session/session-log-deepseek/package.json | 1 + .../tests/feedback-composition.spec.ts | 6 +- .../package.json | 1 + .../tests/provider.e2e.ts | 2 +- .../tests/loader-composition.spec.ts | 1 - .../tool-bash-persistent/tests/tools.spec.ts | 1 - .../shell/tool-bash/tests/background.spec.ts | 2 - .../tests/loader-composition.spec.ts | 1 - .../tool-pwsh-persistent/tests/tools.spec.ts | 1 - .../shell/tool-pwsh/tests/background.spec.ts | 2 - .../skill/tool-skill/tests/tool-skill.spec.ts | 2 - .../subagent/subagent-dsh-sdk/package.json | 1 + .../tests/fixtures/loader/child.patch.yml | 2 +- .../subagent-spawn-in-process/package.json | 1 + .../tests/harness.ts | 2 +- .../subagent/tests/archive-admission.spec.ts | 1 - .../terminal-bash/tests/index.spec.ts | 3 - .../terminal-bash/tests/local.spec.ts | 1 - .../terminal/terminal/tests/service.spec.ts | 1 - .../tests/loader-composition.spec.ts | 1 - .../tool-terminal/tests/tools.spec.ts | 1 - .../tests/loader-composition.spec.ts | 1 - .../tool-workflow/tests/tool-workflow.spec.ts | 2 - packages/workflow/workflow-ptc/package.json | 1 + .../workflow-ptc/tests/workflow-ptc.e2e.ts | 2 +- pnpm-lock.yaml | 130 +++++++++++++++++- python/sdk-runtime/package.json | 2 + .../escalation-approved/cordis.snapshot.yml | 2 +- snapshots/acp/escalation-approved/cordis.yml | 2 +- .../acp/image-compaction/cordis.snapshot.yml | 2 +- .../account-signout.mjs | 2 +- .../sdk/account-provider-signout/cordis.yml | 4 +- snapshots/sdk/bash-tool/cordis.snapshot.yml | 2 +- snapshots/sdk/bash-tool/cordis.yml | 2 +- .../sdk/persistent-tools/cordis.snapshot.yml | 2 +- snapshots/sdk/persistent-tools/cordis.yml | 2 +- .../sdk/serial-created/cordis.snapshot.yml | 2 +- snapshots/sdk/serial-created/cordis.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../child.cordis.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../subagent-dsh-sdk-diagnostic/cordis.yml | 2 +- .../subagent-dsh-sdk-dynamic-route/cordis.yml | 2 +- .../subagent-send-message/cordis.snapshot.yml | 2 +- snapshots/sdk/text-turn/cordis.snapshot.yml | 2 +- snapshots/sdk/text-turn/cordis.yml | 2 +- .../agent-instructions/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../both-mode-turn/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../compaction-recovery/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis-inspect-jsdoc/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../empty-response-retry/cordis.snapshot.yml | 2 +- .../session/empty-response-retry/cordis.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../fs-glob-sampling/cordis.snapshot.yml | 2 +- snapshots/session/fs-glob-sampling/cordis.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../lsp-definition/cordis.snapshot.yml | 2 +- .../model-switch-notice/cordis.snapshot.yml | 2 +- .../multimodal-spill-ends/cordis.snapshot.yml | 2 +- .../session/office-skills/cordis.snapshot.yml | 2 +- .../parallel-tool-calls/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../persistent-pwsh-tool-turn/cordis.yml | 2 +- .../plugin-manager-mcp/cordis.snapshot.yml | 2 +- .../plugin-manager/cordis.snapshot.yml | 2 +- .../product-subagent-both/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../session/provider-cwd/cordis.snapshot.yml | 2 +- .../ptc-node-read-only/cordis.snapshot.yml | 2 +- .../ptc-node-workspace/cordis.snapshot.yml | 2 +- .../ptc-python-turn/cordis.snapshot.yml | 2 +- .../ptc-read-image/cordis.snapshot.yml | 2 +- .../session/ptc-turn/cordis.snapshot.yml | 2 +- .../ptc-workspace-context/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../pwsh-tool-turn/cordis.snapshot.yml | 2 +- snapshots/session/pwsh-tool-turn/cordis.yml | 2 +- .../session/ralph-loop/cordis.snapshot.yml | 2 +- .../read-image-text-route/cordis.snapshot.yml | 2 +- .../session/read-image/cordis.snapshot.yml | 2 +- .../session-query-spill/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../session-sandbox-root/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../subagent-tool-filter/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../system-prompt-in-history/cordis.yml | 2 +- .../session/team-targets/cordis.snapshot.yml | 2 +- .../session/text-turn/cordis.snapshot.yml | 2 +- snapshots/session/text-turn/cordis.yml | 2 +- .../session/web-fetch/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../workflow-confinement/cordis.snapshot.yml | 2 +- .../cordis.snapshot.yml | 2 +- tsconfig.base.json | 2 + tsconfig.host.json | 2 + 302 files changed, 1771 insertions(+), 952 deletions(-) create mode 100644 packages/credentials/deepseek-account/src/account-tasks.ts create mode 100644 packages/credentials/deepseek-account/tests/account-tasks.spec.ts create mode 100644 packages/llm/llm-deepseek-account/README.i18n.yaml create mode 100644 packages/llm/llm-deepseek-account/README.md create mode 100644 packages/llm/llm-deepseek-account/README.zh.md create mode 100644 packages/llm/llm-deepseek-account/package.json create mode 100644 packages/llm/llm-deepseek-account/src/config.ts create mode 100644 packages/llm/llm-deepseek-account/src/index.ts create mode 100644 packages/llm/llm-deepseek-account/tests/provider.spec.ts create mode 100644 packages/llm/llm-deepseek-account/tsconfig.json create mode 100644 packages/llm/llm-deepseek-api-key/README.i18n.yaml create mode 100644 packages/llm/llm-deepseek-api-key/README.md create mode 100644 packages/llm/llm-deepseek-api-key/README.zh.md create mode 100644 packages/llm/llm-deepseek-api-key/package.json create mode 100644 packages/llm/llm-deepseek-api-key/src/config.ts create mode 100644 packages/llm/llm-deepseek-api-key/src/index.ts create mode 100644 packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts create mode 100644 packages/llm/llm-deepseek-api-key/tsconfig.json create mode 100644 packages/llm/llm-deepseek/src/host.ts create mode 100644 packages/llm/llm-deepseek/tests/model-info.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.i18n.yaml index 508dec336c..09412b4916 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.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 .agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md -2026-09-17-account-provider-signout.md: dd9930a7b36fd93c18febb8cca506e59bb605797 -2026-09-17-account-provider-signout.zh.md: e706a1bacc3c3ef2c3b29912fa85da59b02d8d5e +2026-09-17-account-provider-signout.md: 6b8ef842c54ef25d1e5fc063c40361775aae9a92 +2026-09-17-account-provider-signout.zh.md: 588dd38bf9fbae8131a9e3c5cb1181dcc807df8c diff --git a/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md index dd9930a7b3..6b8ef842c5 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md +++ b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.md @@ -10,18 +10,18 @@ Selecting credentials from login state changes the billing identity of an existi ## Decision -`deepseek-official` and `deepseek-account` are independent routes sharing the DeepSeek protocol implementation and validated connection settings. Each adapter has exactly one credential resolver and authentication header mode. Neither route falls back to the other credential. The account service retains the origin and issuer validation defined by the [login decision](2026-09-14-deepseek-account-login.md). +`deepseek-official` and `deepseek-account` are independent routes sharing the DeepSeek protocol implementation. Each adapter has exactly one credential resolver and authentication header mode. Neither route falls back to the other credential. The account service retains the origin and issuer validation defined by the [login decision](2026-09-14-deepseek-account-login.md). -The Agent running phase owns `activeProvider`, the final provider returned by request preparation. The value remains through tools and retry preparation, changes when the next request binds, and clears before `turn/end`. It is undefined during a turn’s first preparation. A missing account credential still rejects the first request before transport. Token resolution checks local sign-out in progress; no login-level signal, credential lease, or synchronization protocol covers the short token-read/send interval. +Account cancellation reads each running Agent’s latest `Session.requestContext()` instead of maintaining another provider field. This logged value remains through tools and retries and follows the next bound request. Idle Agents are excluded. During a new turn’s first preparation it can still name the previous turn’s account route, so sign-out can also interrupt an API-key turn that has not bound its new request yet. A missing account credential rejects a first request without a previous context before transport. -Successful local sign-out publishes an account event. The DeepSeek plugin enumerates the live Agent registry and cancels matching account routes through `Agent.cancel`, preserving inboxes. Each registered child is classified independently; existing parent-owned cancellation still applies to children whose lifetime is attached to a cancelled parent. Independent API-key agents continue. The existing HTTP signal carries cancellation to the transport. +Successful local sign-out publishes an account event. The platform account provider installs the account module’s listener, which enumerates the Agent registry and cancels matching account routes through `Agent.cancel`, preserving inboxes. The account controller shares its predicate for sign-out confirmation. Each registered child is classified independently; existing parent-owned cancellation still applies to children whose lifetime is attached to a cancelled parent. The existing HTTP signal carries cancellation to transport. ## Alternatives considered -A historical set of providers wrongly cancels a turn after it switches to an API-key route. Current settings misclassify an older in-flight request. A module-global map duplicates Agent lifecycle ownership, while making the account service manage every provider reverses the service dependency. A login-level cancellation signal duplicates the existing Agent and HTTP cancellation chain. +A historical set of providers wrongly cancels a turn after it switches to an API-key route. Current settings misclassify an older in-flight request. A second provider field or module-global map duplicates information already recorded in request context. A login-level cancellation signal duplicates the existing Agent and HTTP cancellation chain. ## Consequences -Model selection exposes both routes, and choosing an account route requires login even when an API key exists. The account route shares its model catalog and connection settings with the API-key route. Cancellation preserves queued input without waking it automatically. The recorded hook reason produces a localized conversation notice without changing durable event types. +Model selection exposes both routes, and choosing an account route requires login even when an API key exists. The `llm-deepseek-account` and `llm-deepseek-api-key` plugins independently own catalogs, connection settings, and credentials; both use the `llm-deepseek` protocol library. Cancellation preserves queued input without waking it automatically. The recorded hook reason produces a localized conversation notice without changing durable event types. Behavior tests cover credential separation, first preparation, tool-stage cancellation, and route replacement. The SDK account-provider-signout scenario boots the shipped profile and records interrupted output plus the cancellation cause. diff --git a/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.zh.md b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.zh.md index e706a1bacc..588dd38bf9 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-account-provider-signout.zh.md @@ -10,18 +10,18 @@ Status: implemented ## Decision -`deepseek-official` 和 `deepseek-account` 是独立路由,共享 DeepSeek 协议实现及已校验的连接设置。每个适配器仅有一个凭证解析器和认证请求头模式。两条路由均不回退到另一凭证。账号服务保留[登录决策](2026-09-14-deepseek-account-login.zh.md)定义的来源及签发方校验。 +`deepseek-official` 和 `deepseek-account` 是独立路由,共享 DeepSeek 协议实现。每个适配器仅有一个凭证解析器和认证请求头模式。两条路由均不回退到另一凭证。账号服务保留[登录决策](2026-09-14-deepseek-account-login.zh.md)定义的来源及签发方校验。 -Agent 运行阶段拥有 `activeProvider`,其值是请求准备返回的最终提供方。该值在工具执行及重试准备期间保留,在绑定下一请求时改变,在 `turn/end` 前清除。轮次的首请求准备期间该值为 undefined。账号凭证缺失仍在首请求进入传输前拒绝请求。Token 解析检查本地退登是否进行中;不为短暂的读取 token 到发送间隙引入登录级信号、凭证租约或同步协议。 +账号取消读取运行中 Agent 最近的 `Session.requestContext()`,不维护额外提供方字段。该已记录值在工具和重试阶段保留,随下一次绑定请求更新;空闲 Agent 不计入。新轮次首次准备期间,它仍可能指向上一轮的账号路由,因此退登也可能打断尚未绑定新请求的 API key 轮次。没有历史上下文的首请求若缺少账号凭据,会在传输前失败。 -本地退登成功后发布账号事件。DeepSeek 插件遍历运行中的 Agent 注册表,通过 `Agent.cancel` 取消匹配的账号路由并保留收件箱。每个已注册子代理独立分类;生命周期绑定到已取消父代理的子代理仍受现有父级取消规则约束。独立的 API Key 代理继续运行。现有 HTTP 信号将取消传递到传输层。 +本地退登成功后发布账号事件。Platform 账号提供方安装账号模块的监听器,遍历 Agent 注册表,通过 `Agent.cancel` 取消匹配的账号路由并保留收件箱。账号控制器的退登确认复用同一判断函数。每个已注册子代理独立分类;生命周期绑定到已取消父代理的子代理仍受现有父级取消规则约束。现有 HTTP 信号将取消传递到传输层。 ## Alternatives considered -历史提供方集合会在轮次切换到 API Key 路由后仍错误取消它。当前设置会误判更早的在途请求。模块级全局映射重复拥有 Agent 生命周期,让账号服务管理所有提供方则反转服务依赖。登录级取消信号重复现有 Agent 与 HTTP 取消链路。 +历史提供方集合会在轮次切换到 API Key 路由后仍错误取消它。当前设置会误判更早的在途请求。额外的提供方字段或模块级全局映射重复保存请求上下文中已有的信息。登录级取消信号重复现有 Agent 与 HTTP 取消链路。 ## Consequences -模型选择展示两条路由,即使存在 API Key,选择账号路由仍要求登录。账号路由与 API Key 路由共享模型目录和连接设置。取消保留排队输入,不自动唤醒。已记录的 hook 原因生成本地化对话提示,无须改变持久事件类型。 +模型选择展示两条路由,即使存在 API Key,选择账号路由仍要求登录。两个路由分别由 `llm-deepseek-account` 和 `llm-deepseek-api-key` 注册,拥有独立的模型目录与连接设置,共用 `llm-deepseek` 协议实现。取消保留排队输入,不自动唤醒。已记录的 hook 原因生成本地化对话提示,无须改变持久事件类型。 行为测试覆盖凭证分离、首请求准备、工具阶段取消及路由替换。SDK account-provider-signout 场景启动随附 profile,记录被中断的输出和取消原因。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 81097704db..e72a1f0ce6 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -190,8 +190,10 @@ flowchart LR cfg --> plugin_dsh_base_agent_loop plugin_dsh_base_fs_sandbox["fs-sandbox
@deepseek-ai/dsh-fs-sandbox"] cfg --> plugin_dsh_base_fs_sandbox - plugin_dsh_base_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + plugin_dsh_base_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek-api-key"] cfg --> plugin_dsh_base_llm_deepseek + plugin_dsh_base_llm_deepseek_account["llm-deepseek-account
@deepseek-ai/dsh-llm-deepseek-account"] + cfg --> plugin_dsh_base_llm_deepseek_account ``` | Plugin id | Package / module | @@ -287,7 +289,8 @@ flowchart LR | `system-prompt` | `@deepseek-ai/dsh-system-prompt` | | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek-api-key` | +| `llm-deepseek-account` | `@deepseek-ai/dsh-llm-deepseek-account` | Source config: [`packages/bundle/base/cordis.patch.yml`](../../packages/bundle/base/cordis.patch.yml). diff --git a/apps/cli/package.json b/apps/cli/package.json index b433c2407f..a8e3175754 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -120,6 +120,8 @@ "@deepseek-ai/dsh-host-webserver": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", "@deepseek-ai/dsh-llm-mock-server": "workspace:*", "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 874e216499..b26b3e538c 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1246,7 +1246,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'], ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'], ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'], - ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'], + ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek-api-key'], ['sandbox', '@deepseek-ai/dsh-sandbox-local'], ['session-projection', '@deepseek-ai/dsh-session-projection'], ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index 89936bf4e7..4c2331ed0b 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -25,7 +25,6 @@ try { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, diff --git a/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/cordis.yml b/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/cordis.yml index a74e909b1b..fa96b68772 100644 --- a/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/cordis.yml +++ b/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/cordis.yml @@ -1,7 +1,7 @@ # Keyless generic ACP v1 control-surface patch over `dsh --profile acp`. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: acp diff --git a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml index aec25ed875..21a9ead728 100644 --- a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml +++ b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml @@ -2,7 +2,7 @@ # local provider endpoint is supplied by the snapshot test; the real attachment # store and ACP bridge carry two uploaded images into one model request. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DSH_SNAPSHOT_API_KEY baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL diff --git a/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml b/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml index 79757baa74..148d357d23 100644 --- a/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml @@ -24,6 +24,6 @@ - insert: - id: llm-deepseek-keyless - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: baseURL: 'http://127.0.0.1:9' diff --git a/apps/cli/tests/profiles/headless/tests/harness.ts b/apps/cli/tests/profiles/headless/tests/harness.ts index 9c17fa27f4..c07b4b73fd 100644 --- a/apps/cli/tests/profiles/headless/tests/harness.ts +++ b/apps/cli/tests/profiles/headless/tests/harness.ts @@ -8,7 +8,7 @@ import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import TokenMeter from '@deepseek-ai/dsh-token-meter' import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 4e0fc43235..ba55c91d16 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -17,7 +17,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import NodeRuntime from '@deepseek-ai/dsh-ptc-runtime-node' import Sandbox from '@deepseek-ai/dsh-sandbox-local' import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 4ff31ef398..133e6d9aa0 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 docs/architecture.md -architecture.md: 09ad613e0a985e149579b7c655a503a4602f8ef6 -architecture.zh.md: 9eed57f4cc4f6368d8ce3913baae2cb3116055f9 +architecture.md: 634214943c14fe049f141350cad6518167f52bb1 +architecture.zh.md: 6082c5c86a900c03017fbf15f6c4abd41a48a9f9 diff --git a/docs/architecture.md b/docs/architecture.md index 09ad613e0a..634214943c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,7 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). -The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes. `Agent.activeProvider` identifies the latest prepared request’s provider. +The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 9eed57f4cc..6082c5c86a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -116,7 +116,7 @@ turn/end `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 -循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。`Agent.activeProvider` 标识最近一次已准备请求的提供方。 +循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a05d0be3b6..5b2e450ced 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 4df7032411d67f888c395faee700eb3f9729dfed -config-catalog.zh.md: bf5931d1726e7a5b8df4742bd925942c950c8511 +config-catalog.md: 6bc5481042c4c7db272404c97fbb1225fed227df +config-catalog.zh.md: bb48754fb2724f9dae49a4d0b12f50caa6525ded diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4df7032411..6bc5481042 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1430,96 +1430,38 @@ export interface Config { Source: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) - + -## `@deepseek-ai/dsh-llm-deepseek` +## `@deepseek-ai/dsh-llm-deepseek-account` Requires: `llm` ```ts config-catalog -/** - * Plugin config, validated by the same-named schemastery schema and doubling - * as the `llm-deepseek` settings-section shape. Every field is optional in - * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each - * request (a request without any key fails with `MISSING_CREDENTIAL`, not at - * plugin load), omitted thinking mode uses the provider default, and omitted - * reasoning effort resolves to `high`. - */ -export interface Config { - /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv: Volatile - /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ - baseURL: Volatile - /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ - thinking: Volatile<'enabled' | 'disabled' | undefined> - /** Default thinking effort (default `high`); `off` disables thinking per request. */ - reasoningEffort: Volatile<'off' | 'low' | 'high' | 'max' | undefined> - /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */ - maxTokens: Volatile - /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ - defaultContextWindow: Volatile - /** Advisory models shown by discovery consumers; defaults to V41 Flash and V4 Pro. */ - models: Volatile - /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ - streamIdleTimeoutMs: Volatile - /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */ - maxRequestFilesBytes: Volatile - /** Maximum accumulated base64 image payload after Files API fallback (default 20 MiB). */ - maxInlineRequestImageBytes: Volatile - /** Maximum number of represented images per chat request (default 600). */ - maxImagesPerRequest: Volatile - /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */ - imageOffloadByteQuantum: Volatile - /** Base64-byte removal step after inline fallback exceeds its bound (default 10 MiB). */ - inlineImageOffloadByteQuantum: Volatile - /** Image-count removal step after the request exceeds its count bound (default 20). */ - imageOffloadCountQuantum: Volatile - /** Maximum duration of one request-image Files API resolution (default one minute). */ - filesApiTimeoutMs: Volatile - /** Explicit lifetime assigned to each uploaded image (default seven days). */ - fileExpiresAfterSeconds: Volatile - /** Remaining lifetime below which an indexed file is replaced (default one hour). */ - fileRefreshMarginSeconds: Volatile - /** Oldest harness-owned files deleted before one quota-recovery upload retry (default 100). */ - fileQuotaCleanupBatch: Volatile - /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ - retryPolicy: Volatile -} +/** Account route configuration; authentication comes exclusively from the account service. */ +export type Config = ProtocolConfig +``` -/** One optional model entry advertised by the direct-fetch adapter. */ -export interface DeepSeekCatalogModel { - /** Wire model id accepted by the configured endpoint. */ - id: string - /** Selector label; defaults to {@link id}. */ - name?: string - /** Optional selector detail for deployments with similar model variants. */ - description?: string - /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ - contextWindow?: number - /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ - maxTokens?: number - /** Accepted request modalities; omission is text-only. */ - inputModalities?: ModelModality[] - /** - * Total-pixel budget replacing the published token-grid projection for one - * deterministic request preview, or the 512-by-512 `low` preset; omission - * projects onto the token grid. - */ - imagePixelBudget?: number | 'low' - /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ - imageMaxBytes?: number - /** - * `'in-history'` declares that the endpoint reads the latest `system` - * message at any position of the conversation as the complete effective - * system prompt; omission means only a leading system message is read. - */ - systemPromptUpdate?: SystemPromptUpdate +Depends on: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) + +Source: [`packages/llm/llm-deepseek-account/src/config.ts:5`](../packages/llm/llm-deepseek-account/src/config.ts) + + + +## `@deepseek-ai/dsh-llm-deepseek-api-key` + +Requires: `llm` + +```ts config-catalog +/** Messages configuration with a per-request API-key reference. */ +export interface Config extends ProtocolConfig { + /** Credential reference resolved per request; defaults to DEEPSEEK_API_KEY. */ + apiKeyEnv: Volatile } ``` -Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +Depends on: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) -Source: [`packages/llm/llm-deepseek/src/config.ts:28`](../packages/llm/llm-deepseek/src/config.ts) +Source: [`packages/llm/llm-deepseek-api-key/src/config.ts:10`](../packages/llm/llm-deepseek-api-key/src/config.ts) @@ -4247,6 +4189,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-http-proxy` ([`packages/util/http-proxy/src/index.ts`](../packages/util/http-proxy/src/index.ts)) - `@deepseek-ai/dsh-launch-environment` ([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts)) - `@deepseek-ai/dsh-lazy-require` ([`packages/util/lazy-require/src/index.ts`](../packages/util/lazy-require/src/index.ts)) +- `@deepseek-ai/dsh-llm-deepseek` ([`packages/llm/llm-deepseek/src/index.ts`](../packages/llm/llm-deepseek/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server` ([`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bf5931d172..bb48754fb2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1432,96 +1432,38 @@ export interface Config { 来源: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) - + -## `@deepseek-ai/dsh-llm-deepseek` +## `@deepseek-ai/dsh-llm-deepseek-account` 需要: `llm` ```ts config-catalog -/** - * Plugin config, validated by the same-named schemastery schema and doubling - * as the `llm-deepseek` settings-section shape. Every field is optional in - * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each - * request (a request without any key fails with `MISSING_CREDENTIAL`, not at - * plugin load), omitted thinking mode uses the provider default, and omitted - * reasoning effort resolves to `high`. - */ -export interface Config { - /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv: Volatile - /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ - baseURL: Volatile - /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ - thinking: Volatile<'enabled' | 'disabled' | undefined> - /** Default thinking effort (default `high`); `off` disables thinking per request. */ - reasoningEffort: Volatile<'off' | 'low' | 'high' | 'max' | undefined> - /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */ - maxTokens: Volatile - /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ - defaultContextWindow: Volatile - /** Advisory models shown by discovery consumers; defaults to V41 Flash and V4 Pro. */ - models: Volatile - /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ - streamIdleTimeoutMs: Volatile - /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */ - maxRequestFilesBytes: Volatile - /** Maximum accumulated base64 image payload after Files API fallback (default 20 MiB). */ - maxInlineRequestImageBytes: Volatile - /** Maximum number of represented images per chat request (default 600). */ - maxImagesPerRequest: Volatile - /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */ - imageOffloadByteQuantum: Volatile - /** Base64-byte removal step after inline fallback exceeds its bound (default 10 MiB). */ - inlineImageOffloadByteQuantum: Volatile - /** Image-count removal step after the request exceeds its count bound (default 20). */ - imageOffloadCountQuantum: Volatile - /** Maximum duration of one request-image Files API resolution (default one minute). */ - filesApiTimeoutMs: Volatile - /** Explicit lifetime assigned to each uploaded image (default seven days). */ - fileExpiresAfterSeconds: Volatile - /** Remaining lifetime below which an indexed file is replaced (default one hour). */ - fileRefreshMarginSeconds: Volatile - /** Oldest harness-owned files deleted before one quota-recovery upload retry (default 100). */ - fileQuotaCleanupBatch: Volatile - /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ - retryPolicy: Volatile -} +/** Account route configuration; authentication comes exclusively from the account service. */ +export type Config = ProtocolConfig +``` -/** One optional model entry advertised by the direct-fetch adapter. */ -export interface DeepSeekCatalogModel { - /** Wire model id accepted by the configured endpoint. */ - id: string - /** Selector label; defaults to {@link id}. */ - name?: string - /** Optional selector detail for deployments with similar model variants. */ - description?: string - /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ - contextWindow?: number - /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ - maxTokens?: number - /** Accepted request modalities; omission is text-only. */ - inputModalities?: ModelModality[] - /** - * Total-pixel budget replacing the published token-grid projection for one - * deterministic request preview, or the 512-by-512 `low` preset; omission - * projects onto the token grid. - */ - imagePixelBudget?: number | 'low' - /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ - imageMaxBytes?: number - /** - * `'in-history'` declares that the endpoint reads the latest `system` - * message at any position of the conversation as the complete effective - * system prompt; omission means only a leading system message is read. - */ - systemPromptUpdate?: SystemPromptUpdate +依赖: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) + +来源: [`packages/llm/llm-deepseek-account/src/config.ts:5`](../packages/llm/llm-deepseek-account/src/config.ts) + + + +## `@deepseek-ai/dsh-llm-deepseek-api-key` + +需要: `llm` + +```ts config-catalog +/** Messages configuration with a per-request API-key reference. */ +export interface Config extends ProtocolConfig { + /** Credential reference resolved per request; defaults to DEEPSEEK_API_KEY. */ + apiKeyEnv: Volatile } ``` -Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +依赖: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) -来源: [`packages/llm/llm-deepseek/src/config.ts:28`](../packages/llm/llm-deepseek/src/config.ts) +来源: [`packages/llm/llm-deepseek-api-key/src/config.ts:10`](../packages/llm/llm-deepseek-api-key/src/config.ts) @@ -4249,6 +4191,7 @@ export interface Config { - `@deepseek-ai/dsh-http-proxy`([`packages/util/http-proxy/src/index.ts`](../packages/util/http-proxy/src/index.ts)) - `@deepseek-ai/dsh-launch-environment`([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts)) - `@deepseek-ai/dsh-lazy-require`([`packages/util/lazy-require/src/index.ts`](../packages/util/lazy-require/src/index.ts)) +- `@deepseek-ai/dsh-llm-deepseek`([`packages/llm/llm-deepseek/src/index.ts`](../packages/llm/llm-deepseek/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server`([`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke`([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-native-command`([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index b14b776b69..081eff2e79 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 0b66d761bf95950f6b7031552ea08636b8113427 -event-producer-consumer.zh.md: 5d73788aaff631e7164b335d83f7c0202f2ee9b0 +event-producer-consumer.md: 9db87ff7725beda6d0aa22c7fea301bab3fd9a5c +event-producer-consumer.zh.md: 6d45f01d4cc79d4b746d2f757d05a32d0a2a79ce diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0b66d761bf..9db87ff772 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,23 +9,23 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:365`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:263`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:272`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:395`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:301`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:322`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:339`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:45`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | @@ -40,7 +40,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | -| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | +| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:57`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | +| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 5d73788aaf..6d45f01d4c 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,23 +11,23 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:365`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:263`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:272`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:395`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:301`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:322`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:339`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:45`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | @@ -42,7 +42,8 @@ | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | -| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | +| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:57`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | +| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index fe679d21df..84e4e8fecc 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: e8ac36448d2365ae11b103ede84f55499850232e -persistence-catalog.zh.md: 1928c824c273fce4ce12016edd2f636234f69fc3 +persistence-catalog.md: 730e85c773fe34b8fd12a0aff8107401b54620d6 +persistence-catalog.zh.md: 7829109459c268139f15a804cc2443c6d566360c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index e8ac36448d..730e85c773 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5315,7 +5315,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7248,7 +7248,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:402`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 1928c824c2..7829109459 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5317,7 +5317,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7250,7 +7250,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:402`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 6b93bc0670..909a082aa7 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44608,7 +44608,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:397", + "packages/api/session-controller/src/types.ts:398", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -58191,7 +58191,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:402" + "packages/api/session-controller/src/types.ts:403" ] }, { diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 82bccd08e4..28bbc0d975 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: d6069514aebceb1b226874dc55a1711ee92adbdc -core.zh.md: eb6a4b66509cd572618bcab85f8cb520ee8ddea3 +core.md: 36fee2dfea5e38229f16ddbca2da567eeca0f2c5 +core.zh.md: c807828249fde64df291ca098922829107515d1d diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index d6069514ae..36fee2dfea 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -69,8 +69,6 @@ interface Agent { readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus - /** Latest prepared provider in this turn; retained through tools and retries, absent before preparation and between turns. */ - readonly activeProvider: string | undefined /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context @@ -1105,5 +1103,3 @@ One session committed a different agent preset to its durable log. Consumers inv Source: [`packages/preset/agent-preset-registry/src/types.ts`](../../packages/preset/agent-preset-registry/src/types.ts) - -`Agent.activeProvider` is the latest prepared request’s provider in the current turn. It is undefined before first preparation and between turns, stays unchanged during tools and subsequent preparation, and changes only when the next final request configuration is bound. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index eb6a4b6650..c807828249 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -73,8 +73,6 @@ interface Agent { readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus - /** Latest prepared provider in this turn; retained through tools and retries, absent before preparation and between turns. */ - readonly activeProvider: string | undefined /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context @@ -1115,5 +1113,3 @@ One session committed a different agent preset to its durable log. Consumers inv Source: [`packages/preset/agent-preset-registry/src/types.ts`](../../packages/preset/agent-preset-registry/src/types.ts) - -`Agent.activeProvider` 是当前轮次最近已准备请求的提供方。首请求准备前及轮次之间为 undefined,工具执行和后续请求准备期间保持不变,仅在绑定下一最终请求配置时改变。 diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 509edf1de2..12c977de99 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.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 docs/subsystems/credentials.md -credentials.md: f6d510d48ec92feb28cc3af8820a1e497fad2cdd -credentials.zh.md: 7040fe28cb930a0f2dce9a092124fa24e72cf767 +credentials.md: 46427bd45c50a9ecee173ba05528339692abbb7f +credentials.zh.md: 1c3e6f3d16e08fe06376f77ff79df3ffcc208cde diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index f6d510d48e..46427bd45c 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -414,6 +414,21 @@ Source: [`packages/credentials/credentials/src/types.ts`](../../packages/credent ### `deepseek-account/*` events + + +#### `deepseek-account/model-sign-in-required` — emit + +An account model request requires the user to sign in. + +```ts cordis-catalog +/** An account model request requires the user to sign in. + * @mode emit + */ +'deepseek-account/model-sign-in-required'(): void +``` + +Source: [`packages/credentials/deepseek-account/src/types.ts`](../../packages/credentials/deepseek-account/src/types.ts) + #### `deepseek-account/signed-out` — emit diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 7040fe28cb..1c3e6f3d16 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -414,6 +414,21 @@ Source: [`packages/credentials/credentials/src/types.ts`](../../packages/credent ### `deepseek-account/*` events + + +#### `deepseek-account/model-sign-in-required` — emit + +An account model request requires the user to sign in. + +```ts cordis-catalog +/** An account model request requires the user to sign in. + * @mode emit + */ +'deepseek-account/model-sign-in-required'(): void +``` + +Source: [`packages/credentials/deepseek-account/src/types.ts`](../../packages/credentials/deepseek-account/src/types.ts) + #### `deepseek-account/signed-out` — emit diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index d1d8cc0230..40802fb4a8 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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 docs/subsystems/session.md -session.md: 8a8fbc6a68b414b9e8d658822bc9f2cb26000d8f -session.zh.md: 04a4ed2becfad588bc8c1fcd64cba5d84820ea5a +session.md: 8c060b1a34f9f16ebf0ececd28ecdf24e77f497b +session.zh.md: dc26e5a5dc15251bef1fb93cc53ceff2fee55ab6 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 8a8fbc6a68..8c060b1a34 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -821,9 +821,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Initialize the default after the user first configures a provider credential. + * Initialize a provider default; account login replaces it only when no provider API key is configured. * @param provider - the provider whose credential was configured. - * @returns after saving the initial available model; existing user choices are retained. + * @returns after saving the first available model or retaining the existing default. */ @Remote async initializeDefaultModel(provider: string): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 04a4ed2bec..dc26e5a5dc 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -825,9 +825,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Initialize the default after the user first configures a provider credential. + * Initialize a provider default; account login replaces it only when no provider API key is configured. * @param provider - the provider whose credential was configured. - * @returns after saving the initial available model; existing user choices are retained. + * @returns after saving the first available model or retaining the existing default. */ @Remote async initializeDefaultModel(provider: string): Promise diff --git a/packages/api/account-controller/README.i18n.yaml b/packages/api/account-controller/README.i18n.yaml index 236a988cda..8cd04c057c 100644 --- a/packages/api/account-controller/README.i18n.yaml +++ b/packages/api/account-controller/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/api/account-controller/README.md -README.md: e1d0769e5e76541355f6b725627a833ce399fa2b -README.zh.md: cbc167df735d692c5395bf167c777f1bf17fe8a5 +README.md: a5d3405353245335a45a26eedc4e4281cf045ecb +README.zh.md: 353d79fac4ab0a01a659d9f1ad9d2944fe01f735 diff --git a/packages/api/account-controller/README.md b/packages/api/account-controller/README.md index e1d0769e5e..a5d3405353 100644 --- a/packages/api/account-controller/README.md +++ b/packages/api/account-controller/README.md @@ -22,7 +22,7 @@ Account screens use authenticated Remote commands and a snapshot stream. The con The account namespace exposes getState, getProfile / getBalance, startSignIn, cancelSignIn, signOut, and watch. watch emits an initial complete state and subsequent complete states; disconnecting stops observation, not the login attempt. Cancellation names the attempt ID so a stale screen cannot cancel a newer login. -hasRunningAccountTasks reads the agents registry and reports whether any active prepared provider is deepseek-account, including tool execution and retries. It excludes unprepared turns and API-key providers. The result describes the query instant; sign-out independently cancels matching agents when credentials are removed. +`hasRunningAccountTasks` checks running Agents through the account-owned predicate over their latest logged request context, including tools and retries. Idle Agents and API-key contexts are excluded. The account provider independently cancels matching tasks when credentials are removed. ## Understand the implementation diff --git a/packages/api/account-controller/README.zh.md b/packages/api/account-controller/README.zh.md index cbc167df73..353d79fac4 100644 --- a/packages/api/account-controller/README.zh.md +++ b/packages/api/account-controller/README.zh.md @@ -22,7 +22,7 @@ kind: "package-reference" account 命名空间提供 getState、getProfile / getBalance、startSignIn、cancelSignIn、signOut 和 watch。watch 先发送完整初始状态,随后发送完整状态变化;断开连接只停止观察,不取消登录。取消操作必须指定尝试 ID,防止旧页面取消新登录。 -hasRunningAccountTasks 读取 agents 注册表,判断是否有任务实际使用 deepseek-account,包括工具执行和重试阶段;未完成请求准备的轮次和 API key provider 不计入。结果反映查询时刻;移除凭据时,退登操作会独立取消匹配的 Agent。 +`hasRunningAccountTasks` 通过账号模块的判断函数,检查运行中 Agent 最近记录的请求上下文,包括工具和重试阶段。空闲 Agent 及 API key 上下文不计入。移除凭据时,账号提供方独立取消匹配任务。 ## 理解实现 diff --git a/packages/api/account-controller/src/index.ts b/packages/api/account-controller/src/index.ts index 3984b3b291..d713673499 100644 --- a/packages/api/account-controller/src/index.ts +++ b/packages/api/account-controller/src/index.ts @@ -1,7 +1,7 @@ /** Authenticated Remote operations for account UI consumers. */ import { Context } from '@deepseek-ai/cordis' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import type {} from '@deepseek-ai/dsh-deepseek-account' +import { isRunningAccountTask } from '@deepseek-ai/dsh-deepseek-account' import type {} from '@deepseek-ai/dsh-agent' import type { AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types' import type { AccountView, SignInAttemptId } from './types.ts' @@ -46,12 +46,12 @@ export class AccountController extends TypertRemoteService { @Remote cancelSignIn(attemptId: SignInAttemptId): Promise { return this.ctx.deepseekAccount.cancelSignIn(attemptId) } /** - * Inspect prepared providers across running tasks, including tools and retries. - * @returns whether any task currently uses the account token; unprepared turns are excluded. + * Inspect the latest logged request providers of running tasks, including tools and retries. + * @returns whether running work has a latest request context on the account route. */ @Remote hasRunningAccountTasks(): boolean { - return this.ctx.agents.list().some(agent => agent.activeProvider === 'deepseek-account') + return this.ctx.agents.list().some(isRunningAccountTask) } /** * Remove the local account grant and revoke it through Platform in the background, without deleting API keys. diff --git a/packages/api/account-controller/tests/signout-impact.spec.ts b/packages/api/account-controller/tests/signout-impact.spec.ts index ff618dcae2..837deddd27 100644 --- a/packages/api/account-controller/tests/signout-impact.spec.ts +++ b/packages/api/account-controller/tests/signout-impact.spec.ts @@ -1,21 +1,22 @@ -/** Sign-out impact reads prepared providers rather than configured routes. */ +/** Sign-out impact follows the latest logged provider of running tasks. */ import { Context } from '@deepseek-ai/cordis' import { expect, it } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import { AccountController } from '../src/index.ts' -it('reports only currently prepared account-token tasks', async () => { +it('reports running account requests and excludes idle or unbound tasks', async () => { const ctx = new Context() - const active: Array> = [] - // This query only consumes the registry list and each agent's prepared provider. - ctx.provide('agents') - ctx.set('agents', { list: () => active } as Context['agents']) + const active: Array> = [] + const task = (status: Agent['status'], provider?: string): Pick => ({ + status, session: { requestContext: () => provider === undefined ? undefined : { provider, model: 'model' } } as Agent['session'], + }) + ctx.provide('agents', { list: () => active } as Context['agents']) const controller = new AccountController(ctx) try { expect(controller.hasRunningAccountTasks()).toBe(false) - active.push({ activeProvider: undefined }, { activeProvider: 'deepseek-official' }) + active.push(task('running'), task('running', 'deepseek-official'), task('idle', 'deepseek-account')) expect(controller.hasRunningAccountTasks()).toBe(false) - active.push({ activeProvider: 'deepseek-account' }) + active.push(task('running', 'deepseek-account')) expect(controller.hasRunningAccountTasks()).toBe(true) active.pop() expect(controller.hasRunningAccountTasks()).toBe(false) diff --git a/packages/api/job-controller/tests/host-harness.ts b/packages/api/job-controller/tests/host-harness.ts index 2bd0ef37ea..85609166b0 100644 --- a/packages/api/job-controller/tests/host-harness.ts +++ b/packages/api/job-controller/tests/host-harness.ts @@ -21,7 +21,6 @@ export async function registerAgent(ctx: Context, rawId: string): Promise Promise } = { - activeProvider: undefined, id, options: {}, session, diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts index e578aacf53..583862eeac 100644 --- a/packages/api/job-controller/tests/observe.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -226,7 +226,6 @@ describe('JobController', () => { async function liveOwner(ctx: Context, rawId: string): Promise { const session = ctx.sessions.create(SessionId(rawId)) const owner: Agent = { - activeProvider: undefined, id: session.id, options: {}, session, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index dc33e2f2c0..b0b1168d89 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -97,6 +97,7 @@ "@deepseek-ai/dsh-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-office-to-pdf": "workspace:*", - "@deepseek-ai/dsh-api-account-controller": "workspace:*" + "@deepseek-ai/dsh-api-account-controller": "workspace:*", + "@deepseek-ai/dsh-deepseek-account": "workspace:*" } } diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index b9cecc5b13..f52859acb9 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -7,6 +7,7 @@ */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events' +import type {} from '@deepseek-ai/dsh-deepseek-account/types' import type {} from '@deepseek-ai/dsh-permission-presets/types' import type {} from '@deepseek-ai/dsh-plugin-manager/types' import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' @@ -24,6 +25,7 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'api-session/removed', mode: 'emit' }, { event: 'api-session/status', mode: 'emit' }, { event: 'commands/change', mode: 'emit' }, + { event: 'deepseek-account/model-sign-in-required', mode: 'emit' }, { event: 'credentials/record-updated', mode: 'emit' }, { event: 'credentials/reference-updated', mode: 'emit' }, { event: 'goal/activation-changed', mode: 'emit' }, diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 96ad6d9b61..55407602bc 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -100,6 +100,9 @@ }, { "path": "../account-controller" + }, + { + "path": "../../credentials/deepseek-account" } ] } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 453e76fbe5..9357ca3a8e 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -82,6 +82,9 @@ }, { "path": "../account-controller" + }, + { + "path": "../../credentials/deepseek-account" } ] } diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 881a5d5557..ac8eca4d71 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: 38ee7d644784344e95745d8ddf8d2efc48f18110 -README.zh.md: 75625d404ac68430c80c966c928a080a22b174e3 +README.md: abe79ff7b2b4be6f2b0ba626c77ad9af22fa2f33 +README.zh.md: 7468ed80cb741574a8ab79422ee363af2bbad3e4 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 38ee7d6447..abe79ff7b2 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -78,7 +78,7 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. -GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection or a failed catalog query rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation with the same error code. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. These catalog requirements belong to GUI admission; direct LLM calls can use uncatalogued models. +GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel(provider)` saves the first available model of an API-key provider only before any user choice exists. Account login instead replaces the default when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 75625d404a..7468ed80cb 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -78,7 +78,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 -GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。选择不可用或目录查询失败时,投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查,并使用相同错误码。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。这些目录要求属于 GUI 准入;直接 LLM 调用可使用未列入目录的模型。 +GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存 API key 提供方的第一个可用模型。账号登录则在其他提供方均未配置 API key 时替换默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。 ----- diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 08704ed09d..86889e33d8 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -85,6 +85,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-file-upload": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", @@ -96,6 +97,7 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-title": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", @@ -111,6 +113,9 @@ }, "@deepseek-ai/dsh-session-projection-cache": { "optional": true + }, + "@deepseek-ai/dsh-settings": { + "optional": true } }, "devDependencies": { @@ -127,6 +132,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", @@ -141,6 +147,7 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-title": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-storage": "workspace:*", "@deepseek-ai/dsh-storage-domain": "workspace:*", diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts index e66c6961cb..83ee7a8b93 100644 --- a/packages/api/session-controller/src/catalog.ts +++ b/packages/api/session-controller/src/catalog.ts @@ -1,6 +1,8 @@ /** Shared projection of the live LLM registry into the browser model catalog. */ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type {} from '@deepseek-ai/dsh-settings' import type { LlmModelInfo } from '@deepseek-ai/dsh-llm' import type { Context } from '@deepseek-ai/cordis' import type { @@ -86,3 +88,29 @@ export async function modelAvailable(ctx: Context, selection: ModelSelection): P } return models.some(model => model.id === selection.model) } + +/** + * Check configured provider API-key references independently of model availability. + * @param ctx - Host registry, settings, and credential services. + * @returns whether any API-key provider has a configured credential. + */ +export async function hasProviderApiKey(ctx: Context): Promise { + const settings = ctx.get('settings') + const credentials = ctx.get('credentials') + if (settings === undefined || credentials === undefined) { + throw new RemoteError('session/provider-credentials-unavailable', 'provider credentials are unavailable', {}) + } + const namespaces = settings.describe({ redactSecrets: true }) + for (const provider of ctx.llm.listConfigurableProviders()) { + if (provider.provider === 'deepseek-account') continue + let profile = namespaces.find(namespace => namespace.ns === provider.settingsNs)?.value + for (const key of provider.settingsPath) { + profile = typeof profile === 'object' && profile !== null ? Reflect.get(profile, key) : undefined + } + if (typeof profile !== 'object' || profile === null) continue + const ref: unknown = Reflect.get(profile, 'apiKeyEnv') + if (typeof ref === 'string' && ref.length > 0 + && (await credentials.describe(credentialRef(ref))).configured) return true + } + return false +} diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index e73659d4cb..89300147ab 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -330,8 +330,6 @@ export class SessionCommandController { } const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } - const selection = this.agents.selectionFor(agent).current - await this.requireModel(selection) const source: MessageSource = { kind: 'user', rpcId: request.requestId, @@ -357,7 +355,6 @@ export class SessionCommandController { ) const content = await this.ctx.attachments.admitPromptContent(admission.content) const message: UserMessage = createUserMessage({ content, source }) - await this.requireModel(this.agents.selectionFor(agent).current) if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index db8c6e09b7..b5725bd715 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -23,7 +23,7 @@ import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList } from './list.ts' -import { buildModelCatalog } from './catalog.ts' +import { buildModelCatalog, hasProviderApiKey } from './catalog.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' import { SessionSkillCatalog } from './skill-catalog.ts' import { SessionMediaReferences } from './media-references.ts' @@ -285,19 +285,22 @@ export class SessionController extends TypertRemoteService { } /** - * Initialize the default after the user first configures a provider credential. + * Initialize a provider default; account login replaces it only when no provider API key is configured. * @param provider - the provider whose credential was configured. - * @returns after saving the initial available model; existing user choices are retained. + * @returns after saving the first available model or retaining the existing default. */ @Remote async initializeDefaultModel(provider: string): Promise { + if (provider === 'deepseek-account' && await hasProviderApiKey(this.ctx)) return const catalog = await buildModelCatalog(this.ctx) const model = catalog.groups.find(group => group.id === provider)?.models[0] if (model === undefined) throw new RemoteError('session/provider-models-unavailable', `provider "${provider}" has no available models`, { provider }) - await this.ctx.agentDefaultModel.initializeSelection({ provider, model: model.id, + const selection = { provider, model: model.id, ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }, - }) + } + if (provider === 'deepseek-account') await this.ctx.agentDefaultModel.saveSelection(selection) + else await this.ctx.agentDefaultModel.initializeSelection(selection) } /** diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 677a1da11c..a8f0d7e450 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -201,6 +201,7 @@ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + 'session/provider-credentials-unavailable': Record 'session/provider-models-unavailable': { readonly provider: string } 'session/model-unavailable': { readonly provider: string; readonly model: string } 'session/conflict': { diff --git a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts index 8cb8f0c51d..79bad41b7c 100644 --- a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts +++ b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts @@ -26,7 +26,6 @@ async function harness(): Promise { /** A registered idle Agent double: the gate reads only its Session header. */ async function liveAgent(ctx: Context, session: Session): Promise { const agent: Agent = { - activeProvider: undefined, id: session.id, options: {}, session, diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index a9e3d21747..89f087e71f 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -20,7 +20,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { ApiSessionAgentController } from '../src/agent.ts' -import { buildModelCatalog } from '../src/catalog.ts' +import { buildModelCatalog, hasProviderApiKey } from '../src/catalog.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { createSessionTestController, createSessionTestRemote } from './test-remote.ts' @@ -365,6 +365,66 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('checks configured key references even for empty catalogs and skips profiles without keys', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + const describe = vi.fn(async () => ({ configured: true, writable: true })) + ctx.provide('credentials', { describe } as never) + ctx.provide('settings', { describe: () => [{ ns: 'profiles', value: { + providers: { blank: { apiKeyEnv: '' }, absent: {}, configured: { apiKeyEnv: 'CUSTOM_KEY' } }, + } }] } as never) + const routes = ['missing', 'blank', 'absent', 'configured'] + ctx.effect(() => ctx.llm.registerConfigurableProviders(routes.map(provider => ({ + provider, displayName: provider, settingsNs: 'profiles', + settingsPath: provider === 'missing' ? ['missing', 'nested'] : ['providers', provider], + })))) + try { + expect(await hasProviderApiKey(ctx)).toBe(true) + expect(describe).toHaveBeenCalledExactlyOnceWith('CUSTOM_KEY') + describe.mockRejectedValueOnce(new Error('credential read failed')) + await expect(hasProviderApiKey(ctx)).rejects.toThrow('credential read failed') + } finally { await ctx.fiber.dispose() } + }) + + it.each(['settings', 'credentials'] as const)('refuses account initialization without %s inspection', async (missing) => { + const ctx = new Context() + if (missing !== 'settings') ctx.provide('settings', {} as never) + if (missing !== 'credentials') ctx.provide('credentials', {} as never) + try { + await expect(hasProviderApiKey(ctx)).rejects.toMatchObject({ code: 'session/provider-credentials-unavailable' }) + } finally { await ctx.fiber.dispose() } + }) + + it.each([false, true])('account login replaces a saved default only without another API key: %s', async (configuredKey) => { + const { configurationFixture } = await import('../../../settings/settings/tests/configuration-fixture.ts') + const configured = await configurationFixture({ hmr: false }) + const { ctx } = await harness(undefined, configured.ctx) + ctx.effect(() => ctx.llm.registerAdapter(['deepseek-account'], new CatalogAdapter('Account', [ + { provider: 'deepseek-account', id: 'first-model', name: 'First' }, + { provider: 'deepseek-account', id: 'second-model', name: 'Second' }, + ], REASONING))) + const describe = vi.fn(async () => ({ configured: configuredKey, writable: true })) + ctx.provide('credentials', { describe } as never) + ctx.effect(() => ctx.llm.registerConfigurableProviders([ + { provider: 'deepseek-account', displayName: 'Account', settingsNs: 'account', settingsPath: [] }, + { provider: 'removed-model-provider', displayName: 'Custom', settingsNs: 'first', settingsPath: ['providers', 'custom'] }, + ])) + vi.spyOn(ctx.settings, 'describe').mockReturnValue([{ + ns: 'first' as never, autoGenerate: false, schema: {}, revision: 0, applies: 'live', + value: { providers: { custom: { apiKeyEnv: 'CUSTOM_API_KEY', models: [] } } }, + }]) + await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'saved' }) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', + }) + await controller.initializeDefaultModel('deepseek-account') + expect(describe).toHaveBeenCalledWith('CUSTOM_API_KEY') + expect(ctx.agentDefaultModel.currentSelection()).toEqual(configuredKey + ? { provider: 'removed', model: 'saved' } + : { provider: 'deepseek-account', model: 'first-model', reasoningEffort: 'high' }) + await ctx.fiber.dispose() + }) + it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', @@ -613,7 +673,7 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it.each([1, 2])('reports catalog errors as model-unavailable at prompt check %s', async (check) => { + it('admits saved selections without querying the advisory model catalog', async () => { const { ctx, sessionId, agent } = await harness() const followup = vi.fn() Object.assign(agent, { followup }) @@ -621,17 +681,13 @@ describe('Web session model selection', () => { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) const list = vi.spyOn(ctx.llm, 'listModels') - if (check === 2) list.mockResolvedValueOnce([{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'Chat' }]) list.mockRejectedValueOnce(new Error('credential storage offline')) try { expect(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'hello' }], - }))).toMatchObject({ ok: false, error: { - code: 'session/model-unavailable', message: 'credential storage offline', - details: { provider: 'deepseek-official', model: 'deepseek-chat' }, - } }) - expect(list).toHaveBeenCalledTimes(check) - expect(followup).not.toHaveBeenCalled() + }))).toMatchObject({ ok: true, value: { accepted: true } }) + expect(list).not.toHaveBeenCalled() + expect(followup).toHaveBeenCalledOnce() } finally { list.mockRestore() await ctx.fiber.dispose() @@ -655,22 +711,20 @@ describe('Web session model selection', () => { } }) - it('refuses a prompt no adapter can route, and reports it on the directory', async () => { - const { ctx, sessionId } = await harness() + it('admits an unavailable saved route for execution without rewriting the selection', async () => { + const { ctx, sessionId, agent } = await harness() + const followup = vi.fn() + Object.assign(agent, { followup }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', }) - // The client disabling its input is an affordance; this method stays - // callable, so the refusal has to live here. - const refused = await remote.prompt(promptRequest({ + const admitted = await remote.prompt(promptRequest({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }], })) - expect(refused).toMatchObject({ - ok: false, - error: { code: 'session/model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, - }) + expect(admitted).toMatchObject({ ok: true, value: { accepted: true } }) + expect(followup).toHaveBeenCalledOnce() const unavailableCatalog = await buildModelCatalog(ctx) expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false) @@ -681,14 +735,17 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('refuses a removed model while its provider still serves other models without changing the log', async () => { + it('admits a removed model without changing its saved selection', async () => { const { ctx, sessionId, agent } = await harness({ provider: 'deepseek-official', model: 'removed-model' }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) const events = [...agent.session.snapshotEvents()] + const followup = vi.fn() + Object.assign(agent, { followup }) expect(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'hi' }] }))) - .toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + .toMatchObject({ ok: true, value: { accepted: true } }) + expect(followup).toHaveBeenCalledOnce() expect(agent.session.snapshotEvents()).toEqual(events) expect(currentSelection(ctx, sessionId)).toMatchObject({ provider: 'deepseek-official', model: 'removed-model' }) await ctx.fiber.dispose() diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 48d7ff6e4b..db6701a9b6 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -522,4 +522,7 @@ # key coming from the credential store below. Thinking defaults are a deployment # choice. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' + + - id: llm-deepseek-account + name: '@deepseek-ai/dsh-llm-deepseek-account' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 9bac1d952d..97fee045bc 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -59,7 +59,8 @@ "@deepseek-ai/dsh-goal-round-driver": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-retry": "workspace:*", "@deepseek-ai/dsh-mcp-resources": "workspace:*", diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index d2cc7a47f8..5c8bd6b0f9 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -148,7 +148,6 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ options: createOptions.agentOptions ?? {}, session, inbox, - activeProvider: undefined, status: 'idle', ctx: ownerCtx, cancel: () => {}, diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index e5c9436eba..ac6c7c4bca 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -24,7 +24,7 @@ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DEEPSEEK_API_KEY defaultContextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index d62fa60d58..5786f0d6a0 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -41,7 +41,7 @@ "@deepseek-ai/dsh-invariants": "workspace:*", "@deepseek-ai/dsh-jobs-local": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-retry": "workspace:*", "@deepseek-ai/dsh-mcp-resources": "workspace:*", "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index b059a9d96f..f23bc62084 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -31,7 +31,7 @@ describe('dsh-sdk-minimal bundle', () => { ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'], ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'], ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'], - ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'], + ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek-api-key'], ['sandbox', '@deepseek-ai/dsh-sandbox-local'], ['session-projection', '@deepseek-ai/dsh-session-projection'], ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index c22219a947..2006e11e50 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 3c18d65e996812f08a6c70a74669e8449737968e -README.zh.md: b48d2cf38aba13d42bff524004b077ff0dc179f0 +README.md: 4823aa0a1ba5975c4efc8b6bdddd7ca8c57c068f +README.zh.md: 5ef8a1b6621bf8bab7e98cb18722a9aedda29101 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3c18d65e99..4823aa0a1b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -68,7 +68,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged. -The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. Model availability blocks retain the normal input placeholder while leaving the model picker enabled. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b48d2cf38a..5ef8a1b662 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -68,7 +68,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 模型不可用导致的阻塞保留常规输入占位文案,模型选择器仍可操作。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index dc7e753c88..adace1c113 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -133,7 +133,7 @@ export function ConversationContent(props: ConversationContentProps) { // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, // not a different tree, so the textarea DOM survives the transition. const inert = sessionId === undefined || (hero && chipTitle === undefined) - // A raised block keeps the same textarea mounted: + // A raised block is the same inert posture with the blocker's own reason: // one disabled textarea, never a second tree. The no-workspace state wins // when both hold — picking a workspace is the earlier prerequisite. const blocked = !inert && composerBlock !== undefined @@ -150,7 +150,7 @@ export function ConversationContent(props: ConversationContentProps) { // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the // user clears it. - ? { blocked: composerBlock, placeholder: t(hero ? 'placeholder.hero' : 'placeholder.default') } + ? { blocked: composerBlock, placeholder: composerBlock.reason } : hero ? { placeholder: t('placeholder.hero') } : {}), }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 4a8993091c..90a9bab137 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -425,14 +425,15 @@ describe('ConversationRoot resident composer', () => { expect(dispatchCount()).toBe(before) }) - it('keeps the normal placeholder while model availability blocks sending', () => { + it('renders the composer inert with the blocker\u2019s own reason', () => { const b = mount(sessionSnapshotOf(), undefined, undefined, { composerBlock: { reason: 'select a model first' }, }) const box = b.view.getByRole('textbox') - // Blocking keeps the mounted composer and its normal placeholder. + // One disabled composer with the blocker's placeholder, never a second + // tree: the DOM survives the block being raised and cleared. expect(box.getAttribute('aria-disabled')).toBe('true') - expect(box.getAttribute('data-placeholder')).toBe(zh['placeholder.default']) + expect(box.getAttribute('data-placeholder')).toBe('select a model first') fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).not.toHaveBeenCalled() diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 741628e261..b9eb0ecc76 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/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/client/ui-model-selection/README.md -README.md: aa4a9150a81417241227f99ec747a617640f1302 -README.zh.md: 79516f159d95335561b95bbc8f8be1ecd8ccf9b0 +README.md: b741e05a983fd35ab9d17164b020ff5ba86137b7 +README.zh.md: 1ce5a2b1ae191ac2da86f6a4ac854bd3c9431c8c diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index aa4a9150a8..b741e05a98 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -29,7 +29,7 @@ DeepSeek account and API-key routes appear as separate provider groups, each exp The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it. -Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row clears the effective selection and the trigger prompts `Select model`, without changing the stored choice. +Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged. ### Model and effort @@ -39,9 +39,7 @@ The composer replaces the model and effort text with the Models icon when the ex ### Unroutable sessions -The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Refreshes and refresh failures retain the last displayed selection and groups while sending remains blocked; retry revalidates availability. A Host reset clears that display. The account provider name follows the active locale in both selectors. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. - -Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block. +Catalog availability does not block sending with a saved selection; request execution reports missing credentials or unavailable models. Refreshes and refresh failures retain the last displayed selection and groups. A Host reset clears that display. Sign-out hides the account provider from the picker while preserving the saved provider/model ID and reasoning effort. Signing in restores the catalog name when that model is available again. Existing session logs remain unchanged. ### Selection failures @@ -67,7 +65,7 @@ Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`c Read these pages when the model surface is not enough. They move from the browser surfaces to the command popup shell and the selection contract. - [ui-commands](../ui-commands/README.md) — the popupSelect shell the `/model` contribution registers into. -- [ui-conversation](../ui-conversation/README.md) — declares the composer's `conversation.input.model` seat and the composer block. +- [ui-conversation](../ui-conversation/README.md) — declares the composer's `conversation.input.model` seat. - [dsh-agent-default-model](../../core/agent-default-model/README.md) — the default-model service for sessions that never choose. - [Client package map](../README.md) — adjacent browser UI packages. @@ -86,7 +84,6 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse - These limits define the current model surface. They are current package constraints, not a general model-router comparison or a task backlog. - **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-selection contract. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 79516f159d..1ce5a2b1ae 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -29,7 +29,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。 -与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,有效选择置空,触发器提示“请选择模型”,已保存的选择不变。 +与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。 ### 模型与推理强度 @@ -39,9 +39,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 不可路由的会话 -确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。刷新及刷新失败期间保留上次显示的选择和分组,同时继续禁发;重试会重新确认可用性。Host 重置时清空这些显示。两个选择器中的账号提供方名称均随当前语言显示。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 - -只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。 +目录可用性不会阻止使用已保存选择发送消息;请求执行负责报告凭据缺失或模型不可用。刷新及刷新失败期间保留上次显示的选择和分组。Host 重置时清空这些显示。退登后选择器隐藏账号提供方,同时保留已保存的提供方/模型 ID 和推理强度。再次登录且该模型可用时恢复目录名称。既有会话日志保持不变。 ### 选择失败 @@ -67,7 +65,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 当仅了解模型界面还不够时,请阅读以下页面。这些页面从浏览器界面逐步深入到命令弹窗外壳与选择约定。 - [ui-commands](../ui-commands/README.zh.md)——`/model` 贡献项注册进的 popupSelect 外壳。 -- [ui-conversation](../ui-conversation/README.zh.md)——声明 composer 的 `conversation.input.model` 位与 composer 阻塞块。 +- [ui-conversation](../ui-conversation/README.zh.md)——声明 composer 的 `conversation.input.model` 位。 - [dsh-agent-default-model](../../core/agent-default-model/README.zh.md)——为从未选择的会话提供默认模型的默认模型服务。 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 @@ -86,7 +84,6 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 - 这些限制界定了当前模型选择界面。它们是当前包约束,不是通用模型路由器对比或任务积压。 - **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent(智能体);没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立的模型选择约定。 diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 355dfc7fda..856bfae24d 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -15,7 +15,7 @@ import type { ModelCatalogDirectory } from './catalog.ts' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Last catalog-confirmed selection, retained for display during same-Host refreshes. */ + /** Saved selection, retained even when its provider or model leaves the catalog. */ current: ModelSelection | null /** Saved effort caption retained when the selected model is unavailable. */ retainedEffort?: string @@ -156,12 +156,12 @@ export class ModelDirectory { return } const selection = projected.next ?? catalog.value.default - const current = catalog.value.groups.some(group => group.id === selection.provider - && group.models.some(model => model.id === selection.model)) ? selection : null + const routable = catalog.value.groups.some(group => group.id === selection.provider + && group.models.some(model => model.id === selection.model)) this.store.set({ - current, + current: selection, ...retainedEffort === undefined ? {} : { retainedEffort }, - routable: current !== null, + routable, groups: catalog.value.groups, failures: catalog.value.failures, status: this.store.getSnapshot().status === 'selecting' diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 12f81cec2f..7451f225bf 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -135,9 +135,7 @@ export function apply(ctx: ClientContext): void { // through the bound translate; the seat component reads the standard seat. const t = ctx.locale.bind(NS) - // The composer-block reason is this plugin's own copy, read at raise time so - // a locale change reaches the next publish. - ctx.plugin(ModelDirectoryResolver, { blockReason: () => t('blocked.composer') }) + ctx.plugin(ModelDirectoryResolver) // Entry 1: the /model popupSelect over the shared directory. ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => { diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 9d60b95926..bb61b7e931 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -31,7 +31,6 @@ export const zh = { 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', 'empty.models': '没有可用的模型。', - 'blocked.composer': '当前模型不可用,请先选择模型', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record @@ -61,6 +60,5 @@ export const en = { 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', 'empty.models': 'No models available.', - 'blocked.composer': 'This model is unavailable — select one to continue', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index cee8e630d1..1e8fe46c0a 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -39,16 +39,11 @@ export class ModelDirectoryResolver extends Service { private readonly live: LiveState = { directories: new WeakMapWithValues() } private readonly catalog: ModelCatalogDirectory - /** Localized composer-block copy; this plugin owns the string it raises. */ - private readonly blockReason: () => string - /** * @param ctx - owning root context (the service registers itself as `models`). - * @param config - the bound translator for this plugin's own dictionary. */ - constructor(ctx: Context, config: { blockReason: () => string }) { + constructor(ctx: Context) { super(ctx, 'modelDirectories') - this.blockReason = config.blockReason this.catalog = new ModelCatalogDirectory(ctx) void this.catalog.load().catch(() => { /* selectors expose the shared error */ }) ctx.on('connection/reset', () => { @@ -84,25 +79,6 @@ export class ModelDirectoryResolver extends Service { binding.session.projections.faceOf('modelSelection'), ) live.directories.set(binding, directory) - const conversation = this.ctx.get('conversation') - if (conversation !== undefined) { - const publish = (): void => { - if (sessions.binding(sessionId) !== binding) return - conversation.blocks.set(sessionId, directory.store.getSnapshot().routable !== true - ? { reason: this.blockReason() } - : undefined) - } - publish() - actx.effect(() => { - const stop = directory.store.subscribe(publish) - return () => { - stop() - const current = sessions.binding(sessionId) - if (current !== undefined && current !== binding && live.directories.get(current) !== undefined) return - conversation.blocks.set(sessionId, undefined) - } - }, 'ui-model-selection: composer block') - } actx.effect(() => () => { directory.dispose() live.directories.delete(binding) diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index eca46c5e64..0d035379da 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -356,7 +356,7 @@ describe('ui-model-selection dual entry', () => { }) }) - it('blocks until a refreshed catalog validates the projected selection', async () => { + it('retains the selected model while refreshing its catalog', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) @@ -383,7 +383,7 @@ describe('ui-model-selection dual entry', () => { }) }) - it('retains the last catalog on refresh failure, blocks sending, and recovers on retry', async () => { + it('retains the last catalog on refresh failure and recovers on retry', async () => { const b = await bench() try { b.mint('s1') @@ -397,7 +397,7 @@ describe('ui-model-selection dual entry', () => { groups: GROUPS, routable: null, status: 'error', error: 'catalog offline', }) }) - expect(b.blockOf('s1')).toBeDefined() + expect(b.blockOf('s1')).toBeUndefined() b.setCatalogFailure(false) await b.ctx.modelDirectories.directoryFor(sid('s1')).load() expect(face.directory.getSnapshot().routable).toBe(true) @@ -417,68 +417,6 @@ describe('ui-model-selection dual entry', () => { expect(face2.directory).not.toBe(face1.directory) }) - it('keeps the replacement directory block when the previous scope finishes cleanup', async () => { - const b = await bench() - const first = b.mint('s1') - const oldDirectory = b.ctx.modelDirectories.directoryFor(sid('s1')) - await oldDirectory.load() - const replacement = b.mint('s1') - replacement.projection.set({ lastUsed: null, next: { provider: 'missing', model: 'unserved' } }) - const directory = b.ctx.modelDirectories.directoryFor(sid('s1')) - try { - expect(directory).not.toBe(oldDirectory) - expect(directory.store.getSnapshot().routable).toBe(false) - expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) - await first.fiber.dispose() - expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) - await replacement.fiber.dispose() - expect(b.blockOf('s1')).toBeUndefined() - } finally { - await Promise.all([first.fiber.dispose(), replacement.fiber.dispose()]) - await b.ctx.fiber.dispose() - } - }) - - it.each([false, true])('ignores late old projection publication while the replacement is routable=%s', async (routable) => { - const b = await bench() - const first = b.mint('s1') - const oldDirectory = b.ctx.modelDirectories.directoryFor(sid('s1')) - await oldDirectory.load() - const replacement = b.mint('s1') - replacement.projection.set({ lastUsed: null, - next: { provider: routable ? 'deepseek-official' : 'missing', model: 'deepseek-v4-flash' } }) - const directory = b.ctx.modelDirectories.directoryFor(sid('s1')) - try { - expect(directory.store.getSnapshot().routable).toBe(routable) - const expected = b.blockOf('s1') - await Promise.resolve().then(() => { - first.projection.set({ lastUsed: null, - next: { provider: routable ? 'missing' : 'deepseek-official', model: 'deepseek-v4-pro' } }) - }) - expect(oldDirectory.store.getSnapshot().routable).toBe(!routable) - expect(b.blockOf('s1')).toBe(expected) - } finally { - await Promise.all([first.fiber.dispose(), replacement.fiber.dispose()]) - await b.ctx.fiber.dispose() - } - }) - - it('clears an obsolete block when the new binding has no model directory', async () => { - const b = await bench() - const first = b.mint('s1') - first.projection.set({ lastUsed: null, next: { provider: 'missing', model: 'unserved' } }) - await b.ctx.modelDirectories.directoryFor(sid('s1')).load() - const replacement = b.mint('s1') - try { - expect(b.blockOf('s1')).toBeDefined() - await first.fiber.dispose() - expect(b.blockOf('s1')).toBeUndefined() - } finally { - await Promise.all([first.fiber.dispose(), replacement.fiber.dispose()]) - await b.ctx.fiber.dispose() - } - }) - it('retains saved effort for existing and new sessions after credentials disappear', async () => { const b = await bench() try { @@ -492,17 +430,17 @@ describe('ui-model-selection dual entry', () => { b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) expect(existing.store.getSnapshot().retainedEffort).toBe('High') await vi.waitFor(() => { - expect(existing.store.getSnapshot()).toMatchObject({ current: null, routable: false, retainedEffort: 'High' }) + expect(existing.store.getSnapshot()).toMatchObject({ current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' }, routable: false, retainedEffort: 'High' }) }) b.mint('new') const fresh = b.ctx.modelDirectories.directoryFor(sid('new')) - expect(fresh.store.getSnapshot()).toMatchObject({ current: null, routable: false, retainedEffort: 'Max' }) + expect(fresh.store.getSnapshot()).toMatchObject({ current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, routable: false, retainedEffort: 'Max' }) } finally { await b.ctx.fiber.dispose() } }) - it('blocks the composer when current catalog models disappear', async () => { + it('keeps the composer usable when current catalog models disappear', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) @@ -518,7 +456,7 @@ describe('ui-model-selection dual entry', () => { b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() await Promise.resolve() - expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) + expect(b.blockOf('s1')).toBeUndefined() expect(b.calls.models).toBe(2) // Recovering clears it without a reload of the surface. @@ -530,29 +468,16 @@ describe('ui-model-selection dual entry', () => { expect(b.calls.models).toBe(3) }) - it('hides an unavailable durable selection without replacing or rewriting it', async () => { + it('retains an unavailable durable selection without replacing or rewriting it', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) const intended = { provider: 'deepseek-official', model: 'unlisted' } b.setProjected(sid('s1'), { lastUsed: intended, next: intended }) await vi.waitFor(() => { expect(face.directory.getSnapshot().status).toBe('ready') }) - expect(face.directory.getSnapshot().current).toBeNull() - expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) - expect(b.calls.select).toBe(0) - }) - - it('clears its block when the session scope goes', async () => { - const b = await bench() - const scope = b.mint('s1') - b.setRoutable(false) - const face = b.seat().inject!(sid('s1')) - face.load() - b.remote.emit('llm/adapters-updated', []) - await vi.waitFor(() => { expect(b.blockOf('s1')).toBeDefined() }) - - await scope.fiber.dispose() + expect(face.directory.getSnapshot().current).toEqual(intended) expect(b.blockOf('s1')).toBeUndefined() + expect(b.calls.select).toBe(0) }) it('an unknown session fails loud at the seat inject', async () => { diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index feef74b1bd..947d1fea75 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SessionId } from '@deepseek-ai/dsh-session/types' @@ -144,6 +144,19 @@ describe('ModelSelect reasoning effort', () => { expect(screen.queryByText('Fast catalog description')).toBeNull() }) + it.each(['model', 'provider'])('keeps the saved id and effort when the selected %s disappears', (removed) => { + const directory = createSnapshotStore(state({ retainedEffort: 'High' })) + render() + expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toContain('DeepSeek-V4-Flash') + act(() => { directory.update((snapshot) => { + snapshot.groups = removed === 'provider' ? [] : snapshot.groups.map(group => ({ ...group, models: [] })) + snapshot.routable = false + }) }) + expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent) + .toMatchInlineSnapshot('"deepseek-official/deepseek-v4-flashHigh"') + expect(directory.getSnapshot().current).toEqual(state().current) + }) + it('shows loading until the catalog and Session projection are both ready', async () => { const directory = createSnapshotStore(state({ current: null, @@ -491,3 +504,19 @@ it.each([en, zh])('localizes the account group while preserving external names', expect(screen.getByRole('group', { name: copy['provider.account'] })).toBeTruthy() expect(screen.getByRole('group', { name: 'My Gateway' })).toBeTruthy() }) + +it('restores the account model name after login without changing the saved route', () => { + const groups = [{ id: 'deepseek-account', name: 'DeepSeek Account', models: [ + { id: 'deepseek-flash', name: 'DeepSeek Flash', reasoning }, + ] }] + const selected = { provider: 'deepseek-account', model: 'deepseek-flash', reasoningEffort: 'high' } + const directory = createSnapshotStore(state({ current: selected, groups, retainedEffort: 'High' })) + render() + expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh') + act(() => { directory.update((snapshot) => { snapshot.groups = []; snapshot.routable = false }) }) + expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent) + .toMatchInlineSnapshot('"deepseek-account/deepseek-flashHigh"') + act(() => { directory.update((snapshot) => { snapshot.groups = groups; snapshot.routable = true }) }) + expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh') + expect(directory.getSnapshot().current).toEqual(selected) +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index d2992bfd87..429a8a450e 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/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/client/ui-settings-account/README.md -README.md: 55a747896f5c115d08ebacea6046b4de711a530c -README.zh.md: 582a1b21113631305eb38bd02a140479704d365d +README.md: 2a539ab2ed5836e8a9a2afaf4ff49f19e8d20fe8 +README.zh.md: b3d7cd1fb85ed445a32b796cd344c27b19d30229 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 55a747896f..2a539ab2ed 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -19,6 +19,8 @@ Desktop Account settings display DeepSeek login state and offer browser authoriz - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +A new account-model sign-in-required event displays “Model unavailable. Please sign in and try again.” in a toast. The notice is transient: remounting or refreshing does not replay it. + ## Use this package @@ -57,7 +59,7 @@ A terminal account-state stream failure appears in the sign-in dialog or Account The balance card shows recharge funds and positive bonus credit in separate rows. Empty or nonpositive bonus wallets hide the bonus row and its divider; currencies retain their own amounts. -A successful first login initializes the default model from `deepseek-account` when no user choice has been saved. Later logins retain the saved choice, including an unavailable one. +A successful login selects the first available `deepseek-account` model as the default when no other provider has a configured API key, even if a previous default was saved. Configured keys preserve the existing default even when their provider has no available models. Session-specific selections remain unchanged. ## Model Experience diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 582a1b2111..b3d7cd1fb8 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -19,6 +19,8 @@ Desktop 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览 - [模型体验](#model-experience) - [已知限制与后续工作](#known-limitations-and-deferred-work) +新的账号模型登录要求事件会弹出“当前模型暂不可用,请登录后再试”提示。提示仅保留在当前界面,重新挂载或刷新不会重放。 + ## 使用此包 @@ -57,7 +59,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 余额卡片分行展示充值余额与正额赠金余额。赠送钱包为空或没有正余额时,隐藏赠金行及其分隔线;不同币种分别显示。 -首次成功登录且用户尚未保存模型选择时,从 `deepseek-account` 初始化默认模型。后续登录保留已保存的选择,包括不可用的选择。 +登录成功时,若其他提供方均未配置 API key,则将 `deepseek-account` 的第一个可用模型设为默认值,即使此前保存过默认选择。其他提供方只要配置了 key,就保留原默认值,即使其没有可用模型。会话内的显式选择保持不变。 ## 模型体验 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 641716e16b..a8fab33a82 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -19,12 +19,15 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<' * @returns account menu launcher. */ export function AccountMenu({ - wide, openSettings, openOnboarding, useAccount, useTheme, signOut, hasRunningAccountTasks, contactUs, showLogin, start, cancel, t, + subscribeModelSignInRequired, wide, openSettings, openOnboarding, useAccount, useTheme, signOut, hasRunningAccountTasks, + contactUs, showLogin, start, cancel, t, }: AccountMenuProps) { const account = useAccount(state => state) const colorScheme = useTheme(snapshot => snapshot.active.colorScheme) const signedIn = account.view?.status === 'credential-stored' const expired = account.view?.status === 'signed-out' && account.view.signOutReason === 'expired' + const [signInNotice, setSignInNotice] = useState(0) + useEffect(() => subscribeModelSignInRequired?.(() => { setSignInNotice(value => value + 1) }), [subscribeModelSignInRequired]) const [expiryNotice, setExpiryNotice] = useState(false) useEffect(() => { setExpiryNotice(expired) }, [expired]) const profile = account.details?.profile @@ -42,6 +45,7 @@ export function AccountMenu({ // The plugin's start publishes `loginFailed` before it rejects, so the dialog owns the report. const beginSignIn = (): void => { setOpen(false); void start().catch(() => undefined) } return
+ {signInNotice > 0 && { setSignInNotice(0) }} />} {expiryNotice && { setExpiryNotice(false) }} />} void) => () => void /** Desktop-only commands; absent in ordinary browsers. */ platform?: PlatformBridge diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index d8b844049f..58d68827d1 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -89,6 +89,7 @@ export function apply(ctx: Context): void { })().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) }) const nativePlatform = (globalThis as typeof globalThis & { dshPlatform?: PlatformBridge }).dshPlatform const operations: AccountSectionInjected = { + subscribeModelSignInRequired: listener => ctx.remote.$on('deepseek-account/model-sign-in-required', listener), ...nativePlatform === undefined ? {} : { platform: nativePlatform }, refresh, contactUs() { diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index 22a61c95a8..1c8a62192b 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -1,5 +1,6 @@ /** Account settings copy, owned by the account feature. */ export const en = { + modelSignInRequired: 'Model unavailable. Please sign in and try again.', sessionExpired: 'You have signed out of your account, please log in again.', close: 'Close', addApiKey: 'Add API Key', retry: 'Sign in again', loginTitle: 'Start creating', loginDescription: 'Sign in to create, edit, and share your design projects. Everything is saved locally.', @@ -29,6 +30,7 @@ export const en = { export type AccountKey = keyof typeof en /** Chinese account settings copy. */ export const zh: Record = { + modelSignInRequired: '当前模型暂不可用,请登录后再试', sessionExpired: '登录信息已失效,请重新登录', close: '关闭', addApiKey: '添加 API Key', retry: '重新登录', loginTitle: '开始你的创作', loginDescription: '登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 58fbbd01f4..8905710af7 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -491,3 +491,25 @@ it('reports resize failure, ignores late native failures, and tolerates a remove await act(async () => { loaded.reject(new Error('late failure')); await loaded.promise.catch(() => {}) }) expect(screen.queryByRole('dialog')).toBeNull() }) + +it.each([en, zh])('shows live model sign-in guidance without replaying it after remount', async (copy) => { + const operations = operationsOf({ status: 'signed-out', attempt: null }) + let listener: (() => void) | undefined + const unsubscribe = vi.fn(() => { listener = undefined }) + operations.subscribeModelSignInRequired = (next) => { listener = next; return unsubscribe } + const { AccountMenu } = await import('../src/client/AccountMenu.tsx') + const element = selector(operations.hooks.account.getSnapshot())} + useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide openOnboarding={() => {}} openSettings={() => {}} + t={key => key in copy ? copy[key as AccountKey] : key} /> + const view = render(element) + expect(screen.queryByRole('alert')).toBeNull() + act(() => { listener?.() }) + expect(screen.getByRole('alert').textContent).toBe(copy.modelSignInRequired) + act(() => { listener?.() }) + expect(screen.getAllByRole('alert')).toHaveLength(1) + view.unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + render(element) + expect(screen.queryByRole('alert')).toBeNull() +}) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 90cfff97ab..d76238422d 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 4d91d49e29..5a5592b309 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -8,7 +8,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import * as AgentInstructions from '@deepseek-ai/dsh-agent-instructions' import { candidateScopeKey } from '../src/render.ts' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 5d0264c3ce..454baf309b 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -51,7 +51,6 @@ function sessionAgent(session: Session, id = 'agent'): Agent { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index cd8d778065..ae15f20a46 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -110,7 +110,6 @@ function sessionAgent(session: Session, id = 'agent'): Agent { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index 7ea0e58c92..eac56801bf 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/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/core/agent-default-model/README.md -README.md: 376698f309e311f979a01f39feb1f84a71751713 -README.zh.md: b24b0925504f45553d71e2f2594f359daa29983c +README.md: 2aa3d8ed4e4f36286226f76df4c21dd5cb87be0d +README.zh.md: 7283a44f5f491c1fd51d4c33b6df3f65cb8e76bf diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 376698f309..2aa3d8ed4e 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -7,7 +7,7 @@ kind: "package-reference" English | [中文](README.zh.md) -`initializeSelection()` serializes the first setup choice with user writes and preserves an explicit profile selection, including unavailable models. +`initializeSelection()` serializes the first setup choice with user writes and checks the persisted profile override before writing. It preserves explicit selections, including unavailable models. ## Summary diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index b24b092550..7283a44f5f 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -7,7 +7,7 @@ kind: "package-reference" [English](README.md) | 中文 -`initializeSelection()` 将首次配置选择与用户写入串行处理,并保留显式的 profile 选择,包括暂不可用的模型。 +`initializeSelection()` 将首次配置选择与用户写入串行处理,并在写入前检查持久化的 profile override。它保留显式选择,包括暂不可用的模型。 ## 概述 diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 0100c5baf5..7947f70110 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -52,7 +52,6 @@ export class AgentDefaultModelConfig extends Service { reasoningEffort: z.string().volatile(), }) - private saved = false private writes: Promise = Promise.resolve() constructor(private readonly ownerContext: Context, private config: Config) { @@ -98,12 +97,11 @@ export class AgentDefaultModelConfig extends Service { const editor = this.ctx.get('configEditor') if (entry === undefined || editor === undefined) return const override = editor.configuration().find(row => row.entry === entry)?.override - if (initialize && (this.saved || override !== undefined && ('provider' in override || 'model' in override))) return + if (initialize && override !== undefined && ('provider' in override || 'model' in override)) return await editor.edit(entry, () => ({ provider: next.provider, model: next.model, ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, })) - this.saved = true }) this.writes = operation.catch(() => { /* A refused write does not block the next user choice. */ }) return operation diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 9e33b0b770..406ad5af53 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: c83a0ee287297efba4bc20b2fb8e158e57d87e27 -README.zh.md: 55fbc69270fb4bccc6c54b3738c1e5d94db8f059 +README.md: 07033fb3204e827c5227a99e7211e5e648c389c6 +README.zh.md: 0fce6147b37d704add8266deaf185ea83ae84b73 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index c83a0ee287..07033fb320 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -25,7 +25,6 @@ English | [中文](README.zh.md) ## Use this package -The running phase retains the final prepared provider through tool execution and retry preparation, resets it before publishing `turn/end`, and exposes it through `Agent.activeProvider`. Plugins can cancel matching live agents without consulting mutable settings or historical request headers. Mount `dsh-agent-loop` in any composition that should run agents. It supplies the driver behind `ctx.agents` and starts any agents you declare in its config; both [`dsh-base`](../../bundle/base/README.md) and [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.md) mount it as an explicit row. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 55fbc69270..0fce6147b3 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -25,7 +25,6 @@ kind: "package-reference" ## 使用本包 -运行阶段在工具执行及重试准备期间保留最终准备完成的提供方,在发布 `turn/end` 前重置,并通过 `Agent.activeProvider` 暴露。插件可以取消匹配的运行中代理,无须读取可变设置或历史请求头。 在任何应运行 agent 的组合中挂载 `dsh-agent-loop`。它提供 `ctx.agents` 背后的驱动器,并启动你在配置中声明的 agent;[`dsh-base`](../../bundle/base/README.zh.md) 与 [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.zh.md) 都将它作为显式配置行挂载。 diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index ddfb2645ac..ce45582bd6 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -49,6 +49,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index aeff7b783b..98114f53cd 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -46,7 +46,7 @@ type Phase = lastTurn: number wakeRequested: boolean } - | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean; activeProvider?: string } + | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean } type StepEndReason = Extract @@ -140,10 +140,6 @@ export class ReactLoopAgent implements Agent { return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running' } - get activeProvider(): string | undefined { - return this.phase.kind === 'running' ? this.phase.activeProvider : undefined - } - /** Commit a phase and publish its externally visible status transition. */ private setPhase(next: Phase): void { const previousStatus = this.status @@ -367,7 +363,6 @@ export class ReactLoopAgent implements Agent { this.throwError(error) } finally { try { - delete phase.activeProvider // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending this.session.append('turn/end', { turn, reason: turnEnds! }) } catch (error: unknown) { @@ -579,7 +574,6 @@ export class ReactLoopAgent implements Agent { config = proposedConfig } signal.throwIfAborted() - if (this.phase.kind === 'running') this.phase.activeProvider = config.provider return { config, ...preparedCall === undefined ? {} : { preparedCall } } } diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index f4a9eeb387..739d18b990 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -26,7 +26,6 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx, send: () => {}, diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index ceaa2b0b6d..e36281607d 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -9,7 +9,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' /** * With-key proof that log-derived requests translate into real provider cache hits: a diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index b943742f3d..a5d18afec9 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/core/agent/README.md -README.md: 3d88527a3fed66e2a7bc90ba11ad9c52df0fedec -README.zh.md: cb603d265d9a430f063fcab8024e61f2a8ef7389 +README.md: 17ee5fd723f5322c0ccdfd7cc8443dbb842cf674 +README.zh.md: 94424a5e9bc5a30fbbb5e775658c7b90a37f24f7 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 3d88527a3f..17ee5fd723 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -25,7 +25,6 @@ Use `dsh-agent` to create or resume live agents, send follow-up or steering inpu ## Use this package -The read-only `Agent.activeProvider` identifies the current turn’s latest prepared request. It is absent before the first request is prepared and between turns; tools and retries retain the last provider until another request finishes preparation. Configuration changes alone do not reclassify in-flight work. Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `Agent` handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is `dsh-agent-loop`, so the smallest useful composition loads both. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index cb603d265d..94424a5e9b 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -25,7 +25,6 @@ kind: "package-reference" ## 使用本包 -只读 `Agent.activeProvider` 标识当前轮次最近一次完成准备的请求提供方。首请求准备完成前及轮次之间没有该值;工具执行和重试期间保留上一提供方,直到另一请求完成准备。仅改变配置不会改变在途任务的归属。 在存在实时 agent 的任何地方挂载 `dsh-agent`:它提供 `ctx.agents` 以及插件、UI、钩子和编排器所面向编程的 `Agent` 句柄。在没有驱动器注册工厂之前,该服务保持惰性——随附驱动器是 `dsh-agent-loop`,因此最小的可用组合需要同时加载两者。 diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index bb87e7986e..2118507b8c 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -170,8 +170,6 @@ declare module './types.ts' { readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus - /** Latest prepared provider in this turn; retained through tools and retries, absent before preparation and between turns. */ - readonly activeProvider: string | undefined /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 99f20f2419..c31acf5c66 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -24,7 +24,6 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { inbox: { nextTurn: [], nextStep: [], } as never, - activeProvider: undefined, status: 'idle', ctx, send: () => {}, diff --git a/packages/core/agent/tests/archive-admission.spec.ts b/packages/core/agent/tests/archive-admission.spec.ts index 0969baa21a..0f84de4fe0 100644 --- a/packages/core/agent/tests/archive-admission.spec.ts +++ b/packages/core/agent/tests/archive-admission.spec.ts @@ -15,7 +15,6 @@ async function liveAgent(ctx: Context, rawId: string, initial: AgentStatus): Pro const cancels: CancelCall[] = [] let status = initial const agent: LiveAgent = { - activeProvider: undefined, id, options: {}, session: Session.create(id), diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 3909be12e5..0c882a5bab 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -7,7 +7,7 @@ import { finished } from 'node:stream/promises' import { Context, Service } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' import { z } from 'zod' -import { DeepSeekAccount, desktopClientHeaders, mergePlatformCookies, type PlatformSession, type AccountDetails, type AccountView, type SignInAttemptId, type SignInAttemptView } from '@deepseek-ai/dsh-deepseek-account' +import { DeepSeekAccount, installAccountTaskCancellation, desktopClientHeaders, mergePlatformCookies, type PlatformSession, type AccountDetails, type AccountView, type SignInAttemptId, type SignInAttemptView } from '@deepseek-ai/dsh-deepseek-account' import { credentialKey } from '@deepseek-ai/dsh-credentials' import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization' import { profile, readAccountDetail } from './details.ts' @@ -103,6 +103,7 @@ export class PlatformAccount extends DeepSeekAccount { /** @param ctx - Host with authorization and credentials services. @param config - deployment options. */ constructor(ctx: Context, config: Config = {}) { super(ctx) + installAccountTaskCancellation(ctx) const resolved = Config(config) this.embeddedPageDist = resolved.embeddedPageDist this.origin = platformOrigin(resolved.platformOrigin, resolved.allowLoopbackHttp) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 04ef86deeb..586afa4831 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/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/credentials/deepseek-account/README.md -README.md: eabed7ba74cc9995a46e42eb13eb238a06ec0359 -README.zh.md: 0f48d6286e8eec9d375439e052272e10f2871806 +README.md: 804b929fe27ecdc82569f002dbbc595ae9b0219b +README.zh.md: 8403eb004e33b9cc3e08dca5f1e96c18d1abaee4 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index eabed7ba74..804b929fe2 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -30,10 +30,12 @@ Account consumers read stored login state, start or cancel a browser login, and `AccountProfile.avatarUrl` is an optional profile image URL; null or absence means no avatar. -Successful local sign-out emits `deepseek-account/signed-out`. Consumers own any cancellation policy; the account service does not track agents or model providers. +Successful local sign-out emits `deepseek-account/signed-out`. The platform provider installs the account-owned cancellation listener, which checks running Agents against `session.requestContext().provider` and cancels account tasks while retaining inboxes. The account controller uses the same predicate for its confirmation dialog. No additional Agent state is maintained. Before a new turn binds its first request, this predicate still sees the previous turn’s provider. The service defines account operations and reconnectable state snapshots. The platform provider owns the protocol and stored grant. Credentials are Host-only; the API controller exports state and commands without resolveToken. +Account model failures with `ACCOUNT_SIGN_IN_REQUIRED` emit `deepseek-account/model-sign-in-required`; the Client receives this live event for sign-in guidance. Other request errors do not emit it. + ## Understand the implementation diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index 0f48d6286e..8403eb004e 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -28,12 +28,14 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token ## 使用此包 -本地成功退出登录后发出 `deepseek-account/signed-out`。消费方负责取消策略;账号服务不跟踪 agent 或模型提供方。 +本地成功退出登录后发出 `deepseek-account/signed-out`。Platform 提供方安装账号模块的取消监听器,根据运行中 Agent 的 `session.requestContext().provider` 判断账号任务,取消时保留收件箱。账号控制器的确认弹窗复用同一判断,不维护额外 Agent 状态。新轮次绑定首请求前,该判断仍读取上一轮的提供方。 `AccountProfile.avatarUrl` 是可选的账号头像 URL;为空或缺失时表示没有头像。 该服务定义账号操作和可重连的状态快照。平台提供者负责协议和授权记录。凭证仅限 Host;API 控制器只导出状态与操作,不导出 resolveToken。 +账号模型请求以 `ACCOUNT_SIGN_IN_REQUIRED` 失败时,发出 `deepseek-account/model-sign-in-required`,Client 接收该实时事件以提示登录。其他请求错误不触发此事件。 + ## 理解实现 diff --git a/packages/credentials/deepseek-account/package.json b/packages/credentials/deepseek-account/package.json index b0f00e6a7c..c2ed2bcbbc 100644 --- a/packages/credentials/deepseek-account/package.json +++ b/packages/credentials/deepseek-account/package.json @@ -33,10 +33,19 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-brand": "workspace:*" + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-brand": "workspace:*" + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent": { + "optional": true + } } } diff --git a/packages/credentials/deepseek-account/src/account-tasks.ts b/packages/credentials/deepseek-account/src/account-tasks.ts new file mode 100644 index 0000000000..6325571dd5 --- /dev/null +++ b/packages/credentials/deepseek-account/src/account-tasks.ts @@ -0,0 +1,35 @@ +/** Account sign-out policy over the latest logged request route. */ +import type { Context } from '@deepseek-ai/cordis' +import { LlmError } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from './index.ts' + +/** + * Identify running work whose latest bound request used the account route. + * @param agent - live Agent with its durable request context. + * @returns whether account sign-out should interrupt this task. + */ +export function isRunningAccountTask(agent: Pick): boolean { + return agent.status === 'running' && agent.session.requestContext()?.provider === 'deepseek-account' +} + +/** + * Cancel signed-out account tasks and publish sign-in guidance for rejected requests. + * @param ctx - account provider lifetime; Agents may attach later. + */ +export function installAccountTaskCancellation(ctx: Context): void { + ctx.inject(['agents'], (scope) => { + scope.on('agent/error', ({ error }) => { + if (error instanceof LlmError && error.code === 'ACCOUNT_SIGN_IN_REQUIRED') { + scope.emit('deepseek-account/model-sign-in-required') + } + }) + scope.on('deepseek-account/signed-out', () => { + for (const agent of scope.agents.list()) { + if (isRunningAccountTask(agent)) { + agent.cancel({ kind: 'hook', reason: 'deepseek-account/signed-out' }, { keepInbox: true }) + } + } + }) + }) +} diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index f876c749c8..8197127979 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -1,6 +1,7 @@ /** Account Service Definition shared by platform, API, and model consumers. */ import { Context, Service } from '@deepseek-ai/cordis' import type { AccountDetails, AccountView, SignInAttemptId } from './types.ts' +export { isRunningAccountTask, installAccountTaskCancellation } from './account-tasks.ts' export type { AccountDetails, AccountProfile, AccountWallet, AccountLinks, AccountView, SignInAttemptId, SignInAttemptView, SignInErrorCode } from './types.ts' declare module '@deepseek-ai/cordis' { diff --git a/packages/credentials/deepseek-account/src/types.ts b/packages/credentials/deepseek-account/src/types.ts index ea994d53eb..c88550d101 100644 --- a/packages/credentials/deepseek-account/src/types.ts +++ b/packages/credentials/deepseek-account/src/types.ts @@ -1,4 +1,5 @@ /** Client-safe account state; credentials never cross this projection. */ +import type {} from '@deepseek-ai/cordis' import type { Branded } from '@deepseek-ai/dsh-brand' /** Identity of one local login attempt, unrelated to the platform request ID. */ @@ -47,3 +48,12 @@ export interface AccountDetails { readonly profile: { readonly status: 'ready'; readonly value: AccountProfile } | { readonly status: 'failed' } readonly balance: { readonly status: 'ready'; readonly value: readonly AccountWallet[]; readonly bonusWallets: readonly AccountWallet[] } | { readonly status: 'failed' } } + +declare module '@deepseek-ai/cordis' { + interface Events { + /** An account model request requires the user to sign in. + * @mode emit + */ + 'deepseek-account/model-sign-in-required'(): void + } +} diff --git a/packages/credentials/deepseek-account/tests/account-tasks.spec.ts b/packages/credentials/deepseek-account/tests/account-tasks.spec.ts new file mode 100644 index 0000000000..c2e0ddc9d9 --- /dev/null +++ b/packages/credentials/deepseek-account/tests/account-tasks.spec.ts @@ -0,0 +1,42 @@ +/** Account cancellation follows logged routes and the owning plugin lifetime. */ +import { Context } from '@deepseek-ai/cordis' +import { LlmError } from '@deepseek-ai/dsh-llm' +import { expect, it, vi } from 'vitest' +import { installAccountTaskCancellation } from '../src/account-tasks.ts' + +it('cancels running account tasks with their inboxes retained and detaches with its owner', async () => { + const ctx = new Context() + const cancelAccount = vi.fn() + const cancelOther = vi.fn() + const cancelIdle = vi.fn() + const tasks = [ + { status: 'running', session: { requestContext: () => ({ provider: 'deepseek-account' }) }, cancel: cancelAccount }, + { status: 'running', session: { requestContext: () => ({ provider: 'deepseek-official' }) }, cancel: cancelOther }, + { status: 'idle', session: { requestContext: () => ({ provider: 'deepseek-account' }) }, cancel: cancelIdle }, + { status: 'running', session: { requestContext: () => undefined }, cancel: cancelOther }, + ] + ctx.provide('agents', { list: () => tasks } as never) + const owner = ctx.plugin((scope) => { installAccountTaskCancellation(scope) }) + try { + await owner.await() + ctx.emit('deepseek-account/signed-out') + expect(cancelAccount).toHaveBeenCalledExactlyOnceWith( + { kind: 'hook', reason: 'deepseek-account/signed-out' }, { keepInbox: true }, + ) + expect(cancelOther).not.toHaveBeenCalled() + expect(cancelIdle).not.toHaveBeenCalled() + const notice = vi.fn() + ctx.on('deepseek-account/model-sign-in-required', notice) + const report = (error: unknown) => { ctx.emit('agent/error', { agent: {} as never, turn: 0, step: 0, error }) } + report(new Error('offline')) + report(new LlmError('API key invalid', 'INVALID_API_KEY')) + expect(notice).not.toHaveBeenCalled() + report(new LlmError('Sign in required', 'ACCOUNT_SIGN_IN_REQUIRED')) + expect(notice).toHaveBeenCalledTimes(1) + await owner.dispose() + report(new LlmError('Sign in required', 'ACCOUNT_SIGN_IN_REQUIRED')) + expect(notice).toHaveBeenCalledTimes(1) + ctx.emit('deepseek-account/signed-out') + expect(cancelAccount).toHaveBeenCalledTimes(1) + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/credentials/deepseek-account/tsconfig.json b/packages/credentials/deepseek-account/tsconfig.json index 342f636170..a0da11ddaa 100644 --- a/packages/credentials/deepseek-account/tsconfig.json +++ b/packages/credentials/deepseek-account/tsconfig.json @@ -16,6 +16,12 @@ }, { "path": "../../util/brand" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" } ] } diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index 947d5acedc..6a603163bd 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -35,7 +35,6 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', get ctx() { return scope.ctx }, send: () => {}, diff --git a/packages/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index c6cbbd994a..dd718bc693 100644 --- a/packages/experimental/auto-review/package.json +++ b/packages/experimental/auto-review/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-fs-sandbox": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-permission-presets": "workspace:*", "@deepseek-ai/dsh-ptc-runtime-node": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", diff --git a/packages/experimental/auto-review/tests/auto-review.e2e.ts b/packages/experimental/auto-review/tests/auto-review.e2e.ts index 69de19fa97..55ad0f4a82 100644 --- a/packages/experimental/auto-review/tests/auto-review.e2e.ts +++ b/packages/experimental/auto-review/tests/auto-review.e2e.ts @@ -14,7 +14,7 @@ import { createUserMessage, isAgentLoopRequest, ToolCallId, type StreamChunk, } from '@deepseek-ai/dsh-llm' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import PermissionPresetService, { AUTO_PRESET } from '@deepseek-ai/dsh-permission-presets' import SandboxProvider, { type ConfinedArgv, type SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/experimental/browser-use-runtime/tests/resources.spec.ts b/packages/experimental/browser-use-runtime/tests/resources.spec.ts index 104489f420..f838191d44 100644 --- a/packages/experimental/browser-use-runtime/tests/resources.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/resources.spec.ts @@ -16,7 +16,7 @@ async function fixture() { const fiber = ctx.plugin(() => {}) const session = Session.create(SessionId(id)) const agent: Agent = { - id: session.id, session, ctx: fiber.ctx, options: {}, activeProvider: undefined, status: 'idle', + id: session.id, session, ctx: fiber.ctx, options: {}, status: 'idle', inbox: unsupportedInbox(), send() {}, followup() {}, inject() {}, cancel() {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), runMaintenance: task => task(new AbortController().signal), diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2439bb3b89..804a0f362e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1833,9 +1833,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote async initializeDefaultModel(provider: string): Promise', - description: 'Initialize the default after the user first configures a provider credential.', + description: 'Initialize a provider default; account login replaces it only when no provider API key is configured.', parameters: [{ name: 'provider', description: 'the provider whose credential was configured.' }], - returns: 'after saving the initial available model; existing user choices are retained.', + returns: 'after saving the first available model or retaining the existing default.', }, { signature: '@Remote(\'modelCatalog\') modelCatalog(): Promise', @@ -3883,6 +3883,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation\'s outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.', parameters: [{ name: 'ref', description: 'the reference whose stored value changed.' }], }, + { + name: 'deepseek-account/model-sign-in-required', + mode: 'emit', + signature: '\'deepseek-account/model-sign-in-required\'(): void', + summary: 'An account model request requires the user to sign in.', + description: 'An account model request requires the user to sign in.', + parameters: [], + }, { name: 'deepseek-account/signed-out', mode: 'emit', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 7db9617a08..4e5401c09e 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -38,7 +38,6 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } session, inbox: unsupportedInbox(), ctx: new Context(), - activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index ee442e64f0..273657e579 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -37,7 +37,6 @@ async function agent(ctx: Context): Promise { session, inbox: unsupportedInbox(), ctx: scope.ctx, - activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index bce844f3ee..08acfab775 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index b556ec9270..80ff83d148 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -5,7 +5,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' /** * Build the real fs-tool stack for with-key e2e tests. Agents have no session diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 6abb17030f..1f893f37fe 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -38,7 +38,6 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 3ad29e5b3b..1e4e65c7a1 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -30,7 +30,6 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } session, inbox, ctx: new Context(), - activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 68f71918ab..06e31d7196 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -59,7 +59,6 @@ function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent session, inbox, ctx: agentCtx, - activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 9bc7ae6693..227da38ad8 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -44,7 +44,6 @@ async function liveAgent(ctx: Context, session: Session): Promise { session, inbox: unsupportedInbox(), ctx, - activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 9e606e4bb1..ecc7822bce 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -58,7 +58,6 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St options: {}, session, inbox, - activeProvider: undefined, get status() { return status }, ctx: agentCtx, send: () => {}, diff --git a/packages/jobs/jobs-local/tests/events.spec.ts b/packages/jobs/jobs-local/tests/events.spec.ts index 9ab76bef5a..5a653569b9 100644 --- a/packages/jobs/jobs-local/tests/events.spec.ts +++ b/packages/jobs/jobs-local/tests/events.spec.ts @@ -19,7 +19,6 @@ function agentIn(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { } const session = Session.create(id) return { - activeProvider: undefined, id, options: {}, session, diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index da768539fb..8687f18ef9 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -38,7 +38,6 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle' as const, ctx: agentCtx, send: () => {}, diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index 8b480a856c..c65d056b93 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/llm/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/README.md -README.md: cbd9e386f0f219eea4f41470bd5da076db00ebe0 -README.zh.md: a3a31dfddd38a07ac95aae037f157ac12763974e +README.md: 6b90e917ebb8dc08b8cd74755fa8d93099d5ebcb +README.zh.md: 9afa16f63825a158ae23badc3b8de3b3d7c7b486 diff --git a/packages/llm/README.md b/packages/llm/README.md index cbd9e386f0..6b90e917eb 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -25,7 +25,9 @@ The llm group provides the harness's model-call capability: one provider-neutral | Package | Role | ctx key | |---|---|---| | [`llm/`](llm/README.md) | Streams one model call through a registered provider adapter and shares the harness message, block, and chunk vocabulary | `ctx.llm` | -| [`llm-deepseek/`](llm-deepseek/README.md) | Serves the `deepseek-official` route through DeepSeek Messages with thinking and image input | registers on `ctx.llm` | +| [`llm-deepseek/`](llm-deepseek/README.md) | Shared Messages protocol, request configuration, and model capabilities | — | +| [`llm-deepseek-api-key/`](llm-deepseek-api-key/README.md) | API-key authentication and discovery for the official route | `ctx.llm` | +| [`llm-deepseek-account/`](llm-deepseek-account/README.md) | Account-token authentication, invalidation, and discovery | `ctx.llm` | | [`llm-pi-ai/`](llm-pi-ai/README.md) | Serves configured provider routes through pi-ai catalogs and wire protocols, including hand-declared gateways | registers on `ctx.llm` | | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.md) | Registers lifecycle-owned top-level fields on official DeepSeek requests | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.md) | Contributes the active Loader package inventory to official DeepSeek requests | contributes `dsh_plugin_packages` | diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index a3a31dfddd..9afa16f638 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -25,7 +25,9 @@ llm 组提供 harness 的模型调用能力:一个提供方无关的服务, | 包 | 职责 | ctx key | |---|---|---| | [`llm/`](llm/README.zh.md) | 通过已注册的提供方适配器流式发起一次模型调用,并共享 harness 的消息、块与分片词汇 | `ctx.llm` | -| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 通过 DeepSeek Messages 为 `deepseek-official` 路由提供思考与图片输入 | 注册到 `ctx.llm` | +| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 共享 Messages 协议、请求配置与模型能力 | — | +| [`llm-deepseek-api-key/`](llm-deepseek-api-key/README.zh.md) | API key 鉴权与 official 模型发现 | `ctx.llm` | +| [`llm-deepseek-account/`](llm-deepseek-account/README.zh.md) | 账号 token 鉴权、失效处理与模型发现 | `ctx.llm` | | [`llm-pi-ai/`](llm-pi-ai/README.zh.md) | 通过 pi-ai 目录与协议格式服务配置的提供方路由,包括手工声明的网关 | 注册到 `ctx.llm` | | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.zh.md) | 在官方 DeepSeek 请求上注册具有生命周期归属的顶层字段 | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.zh.md) | 为官方 DeepSeek 请求贡献当前启用的 Loader 包清单 | 贡献 `dsh_plugin_packages` | diff --git a/packages/llm/llm-deepseek-account/README.i18n.yaml b/packages/llm/llm-deepseek-account/README.i18n.yaml new file mode 100644 index 0000000000..29356a8844 --- /dev/null +++ b/packages/llm/llm-deepseek-account/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-account/README.md +README.md: 1186a81f51ff9b9595100fb04d4eb5fb20af6dbc +README.zh.md: fec40b144f55d369e0ef0e6b6d69a6e45fe28f9b diff --git a/packages/llm/llm-deepseek-account/README.md b/packages/llm/llm-deepseek-account/README.md new file mode 100644 index 0000000000..1186a81f51 --- /dev/null +++ b/packages/llm/llm-deepseek-account/README.md @@ -0,0 +1,75 @@ +--- +description: "DeepSeek account authentication and model discovery." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-llm-deepseek-account + +English | [中文](README.zh.md) + +## Summary + +Register authentication and model discovery for `deepseek-account`. This plugin shares the [Messages transport](../llm-deepseek/README.md) and owns credential resolution and catalog availability. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + + +## Use this package + +Only `deepseekAccount.resolveToken(baseURL)` supplies the token; the account service validates the destination. Signed-out or ineligible requests fail with `ACCOUNT_SIGN_IN_REQUIRED` and discovery returns an empty catalog. The route never falls back to an API key. Other credential lookup errors propagate. + +```yaml +- id: llm-deepseek-account + name: '@deepseek-ai/dsh-llm-deepseek-account' + config: + reasoningEffort: high +``` + +An account request HTTP 401 maps to `ACCOUNT_TOKEN_INVALID` independently of the response body. The account service removes the credential only if it still matches the request token. HTTP 403 and other failures do not clear the login. The account module owns task cancellation after sign-out; this plugin does not inspect Agents. + +`models` is an independently configurable catalog for this provider; defaults and protocol capabilities come from the shared transport. Discovery does not probe inference endpoints. The settings namespace is the Cordis entry id, or the plugin name without an entry. Product profiles retain the official entry id `llm-deepseek` and use `llm-deepseek-account` for the account route. + + +## Understand the implementation + +Registrations and listeners dispose with the plugin. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route. No invariant companion is published: discovery derives directly from configuration and credentials without an independent copy. + + +## Further Exploration + +[LLM streaming](../../../docs/subsystems/llm-streaming.md) · [Messages](../llm-deepseek/README.md) + + +## Model Experience + +### Authenticated model requests + +#### What the model sees + +Requests on `deepseek-account` add no model-visible text from this plugin; the shared Messages transport serializes requests. + +#### Token effect + +Authentication and discovery add no input tokens; the selected model and request content determine actual usage. + +#### KV Cache effect + +Credentials and catalog availability do not enter model input; the protocol transport owns request-prefix serialization. + +## Known Limitations and Deferred Work + + + +- The catalog is configuration metadata, not remote validation that a model is accepted; actual calls can still fail. + + +### Dev Note + +Registration and authentication tests also cover the Loader composition in the shared transport package. diff --git a/packages/llm/llm-deepseek-account/README.zh.md b/packages/llm/llm-deepseek-account/README.zh.md new file mode 100644 index 0000000000..fec40b144f --- /dev/null +++ b/packages/llm/llm-deepseek-account/README.zh.md @@ -0,0 +1,75 @@ +--- +description: "DeepSeek 账号 authentication and model discovery." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-llm-deepseek-account + +[English](README.md) | 中文 + +## 概述 + +为 `deepseek-account` 注册独立的鉴权和模型发现插件。通过 [llm-deepseek](../llm-deepseek/README.zh.md) 共用 Messages 请求实现;本包拥有凭据解析和目录可用性判断。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + + +## 使用此包 + +账号 token 仅由 `deepseekAccount.resolveToken(baseURL)` 提供,目标地址必须满足账号服务的来源校验。未登录或目标不允许账号鉴权时,请求以 `ACCOUNT_SIGN_IN_REQUIRED` 失败,目录为空;不会回退到 API key。其他凭据查询错误继续向调用者报告。 + +```yaml +- id: llm-deepseek-account + name: '@deepseek-ai/dsh-llm-deepseek-account' + config: + reasoningEffort: high +``` + +账号请求的 HTTP 401 与响应正文无关:映射为 `ACCOUNT_TOKEN_INVALID`,并要求账号服务仅在当前 token 仍与请求所用 token 相同时清除凭据。403 和其他错误不会触发清除。账号模块负责退登后打断任务,本插件不遍历 Agent。 + +`models` 是该 provider 独立的可配置目录;默认值和协议能力来自共享传输包。目录判断不探测推理端点。设置命名空间采用 Cordis entry id,没有 entry 时采用插件名。产品保留 official 的 `llm-deepseek` entry id,账号使用 `llm-deepseek-account`。 + + +## 理解实现 + +注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。无需 invariant companion:目录直接由同一次配置和凭据读取派生,不维护独立副本。 + + +## 进一步探索 + +[LLM streaming](../../../docs/subsystems/llm-streaming.zh.md) · [Messages](../llm-deepseek/README.zh.md) + + +## 模型体验 + +### 已鉴权的模型请求 + +#### What the model sees + +`deepseek-account` 请求不包含本插件添加的模型可见文本;请求由共享 Messages 传输序列化。 + +#### Token effect + +鉴权和目录过滤不增加输入 token;实际调用的 token 由所选模型与请求内容决定。 + +#### KV Cache effect + +凭据与目录可用性不进入模型输入;协议传输拥有请求前缀的序列化。 + +## 已知限制与后续工作 + + + +- 目录是配置元数据,不验证远端是否接受模型;实际调用仍可能失败。 + + +### 开发备注 + +注册与鉴权的测试也覆盖共享传输包中的 Loader 组合场景。 diff --git a/packages/llm/llm-deepseek-account/package.json b/packages/llm/llm-deepseek-account/package.json new file mode 100644 index 0000000000..f96077c86e --- /dev/null +++ b/packages/llm/llm-deepseek-account/package.json @@ -0,0 +1,54 @@ +{ + "name": "@deepseek-ai/dsh-llm-deepseek-account", + "description": "DeepSeek account provider authentication and discovery", + "version": "0.1.7-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/llm-deepseek-account" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-deepseek-account": "workspace:*", + "@deepseek-ai/cordis-plugin-loader": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-deepseek-account": "workspace:*", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-deepseek-account": { + "optional": true + }, + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + } +} diff --git a/packages/llm/llm-deepseek-account/src/config.ts b/packages/llm/llm-deepseek-account/src/config.ts new file mode 100644 index 0000000000..08c14bdf19 --- /dev/null +++ b/packages/llm/llm-deepseek-account/src/config.ts @@ -0,0 +1,6 @@ +/** Account providers expose protocol settings without an API-key reference. */ +import { Config as ProtocolConfig } from '@deepseek-ai/dsh-llm-deepseek' + +/** Account route configuration; authentication comes exclusively from the account service. */ +export type Config = ProtocolConfig +export const Config = ProtocolConfig diff --git a/packages/llm/llm-deepseek-account/src/index.ts b/packages/llm/llm-deepseek-account/src/index.ts new file mode 100644 index 0000000000..17cf773315 --- /dev/null +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -0,0 +1,47 @@ +/** Account-token authentication and discovery for the DeepSeek account route. */ +import type {} from '@deepseek-ai/cordis-plugin-loader' +import type { Context } from '@deepseek-ai/cordis' +import { LlmError } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-deepseek-account' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { plainOptions, resolveAdapterOptions, registerDeepSeekProvider, catalogModelInfo } from '@deepseek-ai/dsh-llm-deepseek' +import type { ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek' + +import { Config } from './config.ts' +export { Config } from './config.ts' +export const name = 'llm-deepseek-account' +export const inject = ['llm'] + +const PROVIDER = 'deepseek-account' + +export function apply(ctx: Context, config: Config): void { + const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx)) + options() + const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { + const token = await ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) + if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED') + return token + } + ctx.llm.registerConfigurableProviders([ + { provider: PROVIDER, displayName: 'DeepSeek Account', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] }, + ]) + registerDeepSeekProvider(ctx, PROVIDER, { + options, resolveApiKey, providerName: 'DeepSeek Account', accountCredential: true, + onRequestError: async (error, token) => { + if (!(error instanceof LlmError) || error.failure.status !== 401) return error + const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error }) + try { await ctx.get('deepseekAccount')?.rejectToken(token) } + catch (_credentialRemovalFailed) { /* Storage failure cannot replace the inference failure. */ } + return rejected + }, + discoverModels: async (provider) => { + const connection = options() + try { await resolveApiKey(connection) } + catch (error) { + if (error instanceof LlmError && error.code === 'ACCOUNT_SIGN_IN_REQUIRED') return [] + throw error + } + return connection.models.map(model => catalogModelInfo(provider, model)) + }, + }) +} diff --git a/packages/llm/llm-deepseek-account/tests/provider.spec.ts b/packages/llm/llm-deepseek-account/tests/provider.spec.ts new file mode 100644 index 0000000000..1ac9db623d --- /dev/null +++ b/packages/llm/llm-deepseek-account/tests/provider.spec.ts @@ -0,0 +1,44 @@ +/** Account and API-key plugins own independent route registrations and catalogs. */ +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import * as ApiKey from '@deepseek-ai/dsh-llm-deepseek-api-key' +import { afterEach, expect, it, vi } from 'vitest' +import * as Account from '../src/index.ts' + +const contexts: Context[] = [] +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + vi.unstubAllEnvs() +}) + +it('keeps discovery and disposal independent across the two credential routes', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-api-key') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + let token: string | undefined = 'fixture-account-token' + ctx.provide('deepseekAccount', { resolveToken: async (_baseURL: string): Promise => token } as DeepSeekAccount) + const account = ctx.plugin(Account, { models: [{ id: 'account-model', name: 'Account model' }] }) + await account + expect(ctx.llm.listProviders().map(row => row.id)).toEqual(['deepseek-account']) + const official = ctx.plugin(ApiKey, { models: [{ id: 'api-key-model', name: 'API-key model' }] }) + await official + expect((await ctx.llm.listModels('deepseek-account')).map(row => row.id)).toEqual(['account-model']) + expect((await ctx.llm.listModels('deepseek-official')).map(row => row.id)).toEqual(['api-key-model']) + expect(ctx.llm.listConfigurableProviders().map(row => [row.provider, row.settingsNs])).toEqual([ + ['deepseek-account', 'llm-deepseek-account'], ['deepseek-official', 'llm-deepseek-api-key'], + ]) + expect(Account.Config({})).not.toHaveProperty('apiKeyEnv') + token = undefined + expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).toHaveLength(1) + token = 'new-account-token' + expect(await ctx.llm.listModels('deepseek-account')).toHaveLength(1) + await account.dispose() + expect(ctx.llm.listProviders().map(row => row.id)).toEqual(['deepseek-official']) + expect(ctx.llm.listConfigurableProviders().map(row => row.provider)).toEqual(['deepseek-official']) + await official.dispose() + expect(ctx.llm.listProviders()).toEqual([]) + expect(ctx.llm.listConfigurableProviders()).toEqual([]) +}) diff --git a/packages/llm/llm-deepseek-account/tsconfig.json b/packages/llm/llm-deepseek-account/tsconfig.json new file mode 100644 index 0000000000..1f3eb5770a --- /dev/null +++ b/packages/llm/llm-deepseek-account/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../llm" + }, + { + "path": "../llm-deepseek" + }, + { + "path": "../../util/launch-environment" + }, + { + "path": "../../credentials/deepseek-account" + }, + { + "path": "../../../vendor/loader" + } + ] +} diff --git a/packages/llm/llm-deepseek-api-key/README.i18n.yaml b/packages/llm/llm-deepseek-api-key/README.i18n.yaml new file mode 100644 index 0000000000..90a5acc362 --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-api-key/README.md +README.md: 751e041f7aeba7c5969e54fc9a9e45c0417a9c61 +README.zh.md: 7caeaaaad0a9c8b0e93c27244e61fd7f8a6daaed diff --git a/packages/llm/llm-deepseek-api-key/README.md b/packages/llm/llm-deepseek-api-key/README.md new file mode 100644 index 0000000000..751e041f7a --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/README.md @@ -0,0 +1,76 @@ +--- +description: "DeepSeek api-key authentication and model discovery." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-llm-deepseek-api-key + +English | [中文](README.zh.md) + +## Summary + +Register authentication and model discovery for `deepseek-official`. This plugin shares the [Messages transport](../llm-deepseek/README.md) and owns credential resolution and catalog availability. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + + +## Use this package + +`apiKeyEnv` defaults to `DEEPSEEK_API_KEY` and resolves per request. When the credentials service exists, its precedence applies; only compositions without that service read the launch environment directly. Missing credentials fail with `MISSING_CREDENTIAL` and yield an empty catalog; malformed credentials fail with `INVALID_CREDENTIAL`. + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek-api-key' + config: + reasoningEffort: high + apiKeyEnv: DEEPSEEK_API_KEY +``` + +The endpoint and credential reference come from one configuration resolution. In-flight requests retain that snapshot; subsequent updates affect subsequent calls. Account login state cannot change this route’s credential. + +`models` is an independently configurable catalog for this provider; defaults and protocol capabilities come from the shared transport. Discovery does not probe inference endpoints. The settings namespace is the Cordis entry id, or the plugin name without an entry. Product profiles retain the official entry id `llm-deepseek` and use `llm-deepseek-account` for the account route. + + +## Understand the implementation + +Registrations and listeners dispose with the plugin. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route. No invariant companion is published: discovery derives directly from configuration and credentials without an independent copy. + + +## Further Exploration + +[LLM streaming](../../../docs/subsystems/llm-streaming.md) · [Messages](../llm-deepseek/README.md) + + +## Model Experience + +### Authenticated model requests + +#### What the model sees + +Requests on `deepseek-official` add no model-visible text from this plugin; the shared Messages transport serializes requests. + +#### Token effect + +Authentication and discovery add no input tokens; the selected model and request content determine actual usage. + +#### KV Cache effect + +Credentials and catalog availability do not enter model input; the protocol transport owns request-prefix serialization. + +## Known Limitations and Deferred Work + + + +- The catalog is configuration metadata, not remote validation that a model is accepted; actual calls can still fail. + + +### Dev Note + +Registration and authentication tests also cover the Loader composition in the shared transport package. diff --git a/packages/llm/llm-deepseek-api-key/README.zh.md b/packages/llm/llm-deepseek-api-key/README.zh.md new file mode 100644 index 0000000000..7caeaaaad0 --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/README.zh.md @@ -0,0 +1,76 @@ +--- +description: "DeepSeek API key authentication and model discovery." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-llm-deepseek-api-key + +[English](README.md) | 中文 + +## 概述 + +为 `deepseek-official` 注册独立的鉴权和模型发现插件。通过 [llm-deepseek](../llm-deepseek/README.zh.md) 共用 Messages 请求实现;本包拥有凭据解析和目录可用性判断。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + + +## 使用此包 + +`apiKeyEnv` 默认为 `DEEPSEEK_API_KEY`,在每次请求时解析凭据。已组合 credentials 服务时,按其优先级解析;只有未组合该服务时才直接读取启动环境。凭据缺失以 `MISSING_CREDENTIAL` 失败,模型目录为空;格式错误以 `INVALID_CREDENTIAL` 失败。 + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek-api-key' + config: + reasoningEffort: high + apiKeyEnv: DEEPSEEK_API_KEY +``` + +端点和凭据引用来自同一次配置解析;进行中的请求保留该快照,后续配置更新仅影响后续请求。账号登录态不影响该路由使用的凭据。 + +`models` 是该 provider 独立的可配置目录;默认值和协议能力来自共享传输包。目录判断不探测推理端点。设置命名空间采用 Cordis entry id,没有 entry 时采用插件名。产品保留 official 的 `llm-deepseek` entry id,账号使用 `llm-deepseek-account`。 + + +## 理解实现 + +注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。无需 invariant companion:目录直接由同一次配置和凭据读取派生,不维护独立副本。 + + +## 进一步探索 + +[LLM streaming](../../../docs/subsystems/llm-streaming.zh.md) · [Messages](../llm-deepseek/README.zh.md) + + +## 模型体验 + +### 已鉴权的模型请求 + +#### What the model sees + +`deepseek-official` 请求不包含本插件添加的模型可见文本;请求由共享 Messages 传输序列化。 + +#### Token effect + +鉴权和目录过滤不增加输入 token;实际调用的 token 由所选模型与请求内容决定。 + +#### KV Cache effect + +凭据与目录可用性不进入模型输入;协议传输拥有请求前缀的序列化。 + +## 已知限制与后续工作 + + + +- 目录是配置元数据,不验证远端是否接受模型;实际调用仍可能失败。 + + +### 开发备注 + +注册与鉴权的测试也覆盖共享传输包中的 Loader 组合场景。 diff --git a/packages/llm/llm-deepseek-api-key/package.json b/packages/llm/llm-deepseek-api-key/package.json new file mode 100644 index 0000000000..c0bce4980c --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-llm-deepseek-api-key", + "description": "DeepSeek api-key provider authentication and discovery", + "version": "0.1.7-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/llm-deepseek-api-key" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/cordis-plugin-loader": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/cordis-plugin-loader": "workspace:~" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:~" + } +} diff --git a/packages/llm/llm-deepseek-api-key/src/config.ts b/packages/llm/llm-deepseek-api-key/src/config.ts new file mode 100644 index 0000000000..4046483cd7 --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/src/config.ts @@ -0,0 +1,39 @@ +/** API-key configuration resolved together with one Messages endpoint generation. */ +import type { Volatile } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { credentialRef, type CredentialRef } from '@deepseek-ai/dsh-credentials' +import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { deepSeekConfigFields, type Config as ProtocolConfig, plainOptions as protocolOptions, resolveAdapterOptions as resolveProtocolOptions } from '@deepseek-ai/dsh-llm-deepseek' +import type { Options as ProtocolOptions, DeepSeekConnectionOptions } from '@deepseek-ai/dsh-llm-deepseek' + +/** Messages configuration with a per-request API-key reference. */ +export interface Config extends ProtocolConfig { + /** Credential reference resolved per request; defaults to DEEPSEEK_API_KEY. */ + apiKeyEnv: Volatile +} +export const Config = z.object({ + ...deepSeekConfigFields, + apiKeyEnv: z.string().role('credential-ref').default('DEEPSEEK_API_KEY').volatile(), +}) +/** Plain deployment inputs for the API-key provider. */ +export type Options = ProtocolOptions & { apiKeyEnv?: string } +/** Endpoint and credential reference captured from the same configuration generation. */ +export interface ResolvedDeepSeekOptions extends DeepSeekConnectionOptions { + /** Credential reference used only for this connection snapshot. */ + apiKeyEnv: CredentialRef +} +/** Read one validated provider configuration. + * @param config - live plugin configuration. + * @returns detached resolver inputs. + */ +export function plainOptions(config: Config): Options { + return { ...protocolOptions(config), apiKeyEnv: config.apiKeyEnv.get() } +} +/** Resolve API-key and protocol settings together. + * @param config - raw deployment settings. + * @param environment - application launch environment. + * @returns validated endpoint facts and the matching credential reference. + */ +export function resolveAdapterOptions(config: Options, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions { + return { ...resolveProtocolOptions(config, environment), apiKeyEnv: credentialRef(config.apiKeyEnv ?? 'DEEPSEEK_API_KEY') } +} diff --git a/packages/llm/llm-deepseek-api-key/src/index.ts b/packages/llm/llm-deepseek-api-key/src/index.ts new file mode 100644 index 0000000000..ebcba71b54 --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/src/index.ts @@ -0,0 +1,51 @@ +/** API-key authentication and discovery for the official DeepSeek route. */ +import type { Context } from '@deepseek-ai/cordis' +import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { registerDeepSeekProvider, catalogModelInfo } from '@deepseek-ai/dsh-llm-deepseek' +import { Config, plainOptions, resolveAdapterOptions } from './config.ts' +import type { ResolvedDeepSeekOptions } from './config.ts' + +export { Config, plainOptions, resolveAdapterOptions } from './config.ts' +export type { Options, ResolvedDeepSeekOptions } from './config.ts' +export const name = 'llm-deepseek-api-key' +export const inject = ['llm'] + +const PROVIDER = 'deepseek-official' + +export function apply(ctx: Context, config: Config): void { + const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx)) + options() + const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { + const ref = connection.apiKeyEnv + const credentials = ctx.get('credentials') + if (credentials !== undefined) { + const hit = await credentials.resolve(ref) + if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref) + } else { + const ambient = launchEnvironmentOf(ctx).get(ref) + if (ambient !== undefined && ambient.value.length > 0) return assertUsableApiKey(ambient.value, 'llm-deepseek', ref) + } + throw new LlmError( + `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` + + ` service (the web Models page writes it), or export ${ref} in the launching environment`, + 'MISSING_CREDENTIAL', + ) + } + ctx.llm.registerConfigurableProviders([ + { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] }, + ]) + registerDeepSeekProvider(ctx, PROVIDER, { + options, resolveApiKey, providerName: 'DeepSeek', + discoverModels: async (provider) => { + const connection = options() + try { await resolveApiKey(connection) } + catch (error) { + if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return [] + throw error + } + return connection.models.map(model => catalogModelInfo(provider, model)) + }, + }) +} diff --git a/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts b/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts new file mode 100644 index 0000000000..1a55ded9d5 --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts @@ -0,0 +1,18 @@ +/** Discovery preserves actionable credential failures. */ +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import { expect, it, vi } from 'vitest' +import * as ApiKey from '../src/index.ts' + +it('reports malformed API keys instead of advertising an empty catalog', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'invalid\nheader') + const ctx = new Context() + try { + await ctx.plugin(LlmRuntime) + await ctx.plugin(ApiKey, {}) + await expect(ctx.llm.listModels('deepseek-official')).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + } finally { + await ctx.fiber.dispose() + vi.unstubAllEnvs() + } +}) diff --git a/packages/llm/llm-deepseek-api-key/tsconfig.json b/packages/llm/llm-deepseek-api-key/tsconfig.json new file mode 100644 index 0000000000..7556e5085a --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../llm" + }, + { + "path": "../llm-deepseek" + }, + { + "path": "../../util/launch-environment" + }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/schemastery" + } + ] +} diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 7381689090..0d8cde2270 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: 8d8e28faddf91026c249dbbfb0cfc183c815ae9d -README.zh.md: cee5d30a17a39d9e0613747db4b1a0035bef9c53 +README.md: 96fe78a4375cef56814923011c8b7589fe23cf7e +README.zh.md: b63660c30ba1f13a54fd5da2e8360b318bea05c7 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 8d8e28fadd..96fe78a437 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Stream DeepSeek models through `deepseek-official` using the Messages API. Configure the endpoint, credentials, reasoning, and image handling from Cordis YAML or Web settings. Valid settings changes affect subsequent calls while in-flight calls retain their configuration. This package can run beside the [pi-ai adapter](../llm-pi-ai/README.md). +Provide the shared DeepSeek Messages transport, request configuration, and model capabilities. Compose [API-key](../llm-deepseek-api-key/README.md) or [account](../llm-deepseek-account/README.md) plugins for authentication, model discovery, and provider registration. Valid settings changes affect subsequent calls while in-flight calls retain their configuration. This package can run beside the [pi-ai adapter](../llm-pi-ai/README.md). ## Table of Contents @@ -25,7 +25,7 @@ Stream DeepSeek models through `deepseek-official` using the Messages API. Confi ## Use this package -Mount this plugin with the harness LLM service to serve `deepseek-official`. It captures connection options from Config references once per operation. +This package exports the transport library; the provider plugins register routes with the harness LLM service. It captures connection options from Config references once per operation. The adapter accepts the LLM service's [request-only user inputs](../llm/README.md#use-this-package) alongside durable history; omitting request-only identity and attribution does not alter provider content. @@ -36,7 +36,7 @@ Choose this adapter for DeepSeek's official API or a Messages-compatible gateway ### Minimal configuration ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DEEPSEEK_API_KEY # credential reference, resolved per request reasoningEffort: high # optional; off | low | high | max @@ -47,11 +47,10 @@ Choose this adapter for DeepSeek's official API or a Messages-compatible gateway filesApiTimeoutMs: 60000 ``` -A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and core calls still pass unlisted model ids through as text-only routes. GUI selection and submission require a catalog entry. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. +A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and core calls still pass unlisted model ids through as text-only routes. GUI selection requires a catalog entry; a saved selection can still submit requests after its catalog entry disappears. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. | Field | Default | Meaning | |---|---|---| -| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved per request through the credentials seam, then the environment | | `baseURL` | `https://api.deepseek.com/anthropic` | Explicit value, then `$DEEPSEEK_BASE_URL`, then the official root | | `thinking` | `enabled` | Deployment policy; `disabled` locks every request to `off` | | `reasoningEffort` | `high` | Default effort: `off`, `low`, `high`, or `max` | @@ -71,7 +70,7 @@ A request selects the route with `provider: deepseek-official`; the model id pas | `fileQuotaCleanupBatch` | `100` | Oldest harness-owned files removed before one quota retry | | `retryPolicy` | normal, 5 retries | Provider-owned retry policy executed by `dsh-llm-retry` | -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-llm-deepseek) is the exhaustive source for every accepted field and its JSDoc. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-llm-deepseek-api-key) is the exhaustive source for every accepted field and its JSDoc. When [proactive compaction](../../compaction/compaction-basic/README.md#use-this-package) is enabled, `models[].contextWindow` (or `defaultContextWindow` when absent) must exceed the effective request `maxTokens` plus the compaction policy’s `headroomTokens`. Requests without an explicit output cap use the model’s `maxTokens` or the adapter default. For small-window deployments, configure headroom within that capacity; lower `thresholdRatio` to compact earlier. @@ -86,7 +85,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 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. +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. The account provider owns HTTP 401 classification and credential invalidation; the transport passes failures to its callback. ### Streaming with thinking and images @@ -116,7 +115,7 @@ Successful Files responses must contain valid JSON. JSON decoding failures from Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request on the official route without an API key fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key. -Each route advertises models only while its own credential resolves. Missing API keys and missing or ineligible account grants yield an empty catalog; other credential errors remain visible. This check does not probe the inference endpoint. Both configurable provider entries share the same model catalog. +Provider plugins own credential-gated discovery. Their catalogs are configured independently; the transport supplies shared default model metadata and capability resolution. ----- @@ -134,7 +133,7 @@ The plugin is built on one explicit resolve step and one registration fact. `res ### Source map -[`src/index.ts`](src/index.ts) registers the provider and resolves settings and credentials. [`src/adapter.ts`](src/adapter.ts) owns the request lifecycle; [`src/serialize.ts`](src/serialize.ts) and [`src/translate.ts`](src/translate.ts) map model input and streamed output. [`src/file-store.ts`](src/file-store.ts) owns upload reuse and recovery through [`src/files-api.ts`](src/files-api.ts). +[`src/index.ts`](src/index.ts) exports the protocol library; [`src/host.ts`](src/host.ts) binds shared Host services for provider plugins. [`src/adapter.ts`](src/adapter.ts) owns the request lifecycle; [`src/serialize.ts`](src/serialize.ts) and [`src/translate.ts`](src/translate.ts) map model input and streamed output. [`src/file-store.ts`](src/file-store.ts) owns upload reuse and recovery through [`src/files-api.ts`](src/files-api.ts). ### Wire flow @@ -215,4 +214,4 @@ None. **Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. -`deepseek-official` uses only its configured API-key reference; `deepseek-account` uses only the stored DSH grant for the account provider’s allowed inference origin. Both routes share Messages transport, model, and file settings. Missing or ineligible account credentials reject the request with a sign-in prompt; neither route falls back to the other. Chat and Files requests reject redirects. Successful sign-out cancels live agents whose current prepared provider is `deepseek-account`, including tool execution, while preserving pending inbox messages and independent API-key tasks. +`deepseek-official` uses only its configured API-key reference; `deepseek-account` uses only the stored DSH grant for the account provider’s allowed inference origin. Both routes share the Messages transport with independently configured model and file settings. Missing or ineligible account credentials reject the request with a sign-in prompt; neither route falls back to the other. Chat and Files requests reject redirects. The account provider owns sign-out cancellation using running Agents’ logged request contexts, including tool execution; the transport receives the existing request abort signal. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index cee5d30a17..b63660c30b 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -通过 `deepseek-official` 使用 Messages API 流式调用 DeepSeek 模型。在 Cordis YAML 或 Web 设置中配置端点、凭据、推理与图片处理。有效的设置更改在后续请求生效,进行中的请求保留原配置。本包可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。 +提供共享的 DeepSeek Messages 传输、请求配置和模型能力。组合 [API key](../llm-deepseek-api-key/README.zh.md) 或[账号](../llm-deepseek-account/README.zh.md)插件以提供鉴权、模型发现与 provider 注册。有效的设置更改在后续请求生效,进行中的请求保留原配置。本包可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -将此插件与 harness LLM 服务一起挂载以提供 `deepseek-official`。它在每次操作开始时从 Config 引用捕获连接选项。 +本包导出传输库;provider 插件向 harness LLM 服务注册路由。它在每次操作开始时从 Config 引用捕获连接选项。 适配器接受 LLM 服务的[仅供请求使用的 user 输入](../llm/README.zh.md#use-this-package),并可将其与持久历史混用;省略请求输入的身份与来源不会改变提供方内容。 @@ -36,7 +36,7 @@ kind: "package-reference" ### 最小配置 ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DEEPSEEK_API_KEY # credential reference, resolved per request reasoningEffort: high # optional; off | low | high | max @@ -47,11 +47,10 @@ kind: "package-reference" filesApiTimeoutMs: 60000 ``` -请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,核心调用中未列出的模型 id 仍作为纯文本路由原样通过。GUI 选择与提交要求模型具有目录条目。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 +请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,核心调用中未列出的模型 id 仍作为纯文本路由原样通过。GUI 选择要求模型具有目录条目;条目消失后,已保存的选择仍可提交请求。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 | 字段 | 默认值 | 含义 | |---|---|---| -| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 按请求解析的凭据引用:先经凭据 seam,再到环境变量 | | `baseURL` | `https://api.deepseek.com/anthropic` | 显式值优先,其次为 `$DEEPSEEK_BASE_URL`,最后为官方根地址 | | `thinking` | `enabled` | 部署策略;`disabled` 把所有请求锁定为 `off` | | `reasoningEffort` | `high` | 默认强度:`off`、`low`、`high` 或 `max` | @@ -71,7 +70,7 @@ kind: "package-reference" | `fileQuotaCleanupBatch` | `100` | 配额重试前删除的、归 harness 所有的最旧文件数 | | `retryPolicy` | normal,5 次重试 | 由 `dsh-llm-retry` 执行的提供方自有重试策略 | -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-llm-deepseek)是每个受支持字段及其 JSDoc 的穷尽式真源。 +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-llm-deepseek-api-key)是每个受支持字段及其 JSDoc 的穷尽式真源。 启用[主动压缩](../../compaction/compaction-basic/README.zh.md#use-this-package)时,`models[].contextWindow`(未声明时使用 `defaultContextWindow`)必须大于生效请求的 `maxTokens` 与压缩策略 `headroomTokens` 之和。请求未覆盖输出上限时,使用模型的 `maxTokens` 或适配器默认值。小窗口部署应在容量范围内配置余量;降低 `thresholdRatio` 可以提早压缩。 @@ -86,7 +85,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`。两种凭据模式均拒绝重定向。使用账号 token 鉴权的请求返回 HTTP 401 时归类为 `ACCOUNT_TOKEN_INVALID`,不依赖响应体内容。仅当该请求的 token 仍匹配已存登录凭据时才删除它,并发布账号过期状态和 UI 退登通知。API Key 路由和其他 HTTP 状态码不会删除账号凭据。 +Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。账号 provider 负责 HTTP 401 分类和凭据失效处理;传输层将错误交给其回调。 ### 带 thinking 与图片的流式调用 @@ -116,7 +115,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。官方路由缺少 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 -每条路由仅在其自身凭据可解析时公布模型。缺少 API Key 或账号授权缺失、不适用时返回空目录;其他凭据错误仍会暴露。此检查不会探测推理端点。两个可配置提供方条目共用同一份模型目录。 +provider 插件拥有按凭据可用性过滤的模型发现。两者的目录独立配置;传输层提供共享的默认模型元数据和能力解析。 ----- @@ -134,7 +133,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 ### 源码导航 -[`src/index.ts`](src/index.ts) 注册提供方并解析设置与凭据。[`src/adapter.ts`](src/adapter.ts) 管理请求生命周期;[`src/serialize.ts`](src/serialize.ts) 和 [`src/translate.ts`](src/translate.ts) 映射模型输入与流式输出。[`src/file-store.ts`](src/file-store.ts) 通过 [`src/files-api.ts`](src/files-api.ts) 管理上传复用与恢复。 +[`src/index.ts`](src/index.ts) 导出协议库;[`src/host.ts`](src/host.ts) 为 provider 插件绑定共享 Host 服务。[`src/adapter.ts`](src/adapter.ts) 管理请求生命周期;[`src/serialize.ts`](src/serialize.ts) 和 [`src/translate.ts`](src/translate.ts) 映射模型输入与流式输出。[`src/file-store.ts`](src/file-store.ts) 通过 [`src/files-api.ts`](src/files-api.ts) 管理上传复用与恢复。 ### 协议流程 @@ -215,4 +214,4 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 **运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 -`deepseek-official` 仅使用配置的 API Key 引用;`deepseek-account` 仅在账号提供方允许的推理来源使用已保存的 DSH 授权。两条路由共享 Messages 传输、模型和文件设置。账号凭证缺失或不适用于目标时拒绝请求并提示登录;两条路由均不回退到另一凭证。Chat 和 Files 请求拒绝重定向。退登成功会取消当前已准备提供方为 `deepseek-account` 的运行代理,包括工具执行阶段,同时保留待处理消息和独立的 API Key 任务。 +`deepseek-official` 仅使用配置的 API Key 引用;`deepseek-account` 仅在账号提供方允许的推理来源使用已保存的 DSH 授权。两条路由共享 Messages 传输,模型与文件设置独立配置。账号凭证缺失或不适用于目标时拒绝请求并提示登录;两条路由均不回退到另一凭证。Chat 和 Files 请求拒绝重定向。账号提供方根据运行中 Agent 已记录的请求上下文负责退登取消,包括工具执行阶段;传输层接收现有请求的中止信号。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 2b66ce0197..249de218ec 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -31,16 +31,13 @@ "@deepseek-ai/dsh-anonymous-user-id": "workspace:*", "@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", - "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", - "@deepseek-ai/cordis-plugin-loader": "workspace:~", - "@deepseek-ai/dsh-deepseek-account": "workspace:*", - "@deepseek-ai/dsh-agent": "workspace:*" + "@deepseek-ai/cordis-plugin-loader": "workspace:~" }, "dependencies": { "@deepseek-ai/cosmokit": "workspace:~", @@ -50,6 +47,8 @@ "eventsource-parser": "^3.1.0" }, "devDependencies": { + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-anonymous-user-id": "workspace:*", @@ -80,12 +79,6 @@ "peerDependenciesMeta": { "@deepseek-ai/cordis-plugin-loader": { "optional": true - }, - "@deepseek-ai/dsh-deepseek-account": { - "optional": true - }, - "@deepseek-ai/dsh-agent": { - "optional": true } } } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 2010a72dcf..2092e1227a 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -4,7 +4,7 @@ import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, ImageAttachmentAccessResolver, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' import type { DeepSeekLlmApiJson } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { catalogModelInfo, modelInfo } from './model-info.ts' +import { modelInfo } from './model-info.ts' import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions as Connection } from './types.ts' import { DeepSeekFileStore } from './file-store.ts' import { MESSAGES_FILES_BETA, messagesApiRoot } from './messages-api.ts' @@ -17,28 +17,22 @@ import { translate } from './translate.ts' import { providerError, providerErrorDetail } from './transport.ts' /** DeepSeek provider using Messages content and native thinking replay. */ -export class DeepSeekAdapter extends LlmAdapter { +export class DeepSeekAdapter extends LlmAdapter { private readonly files: DeepSeekFileStore private readonly imageAccess: ImageAttachmentAccessResolver = (ref) => { const attachments = this.dependencies.resolveAttachments?.() return attachments === undefined ? undefined : this.dependencies.resolveImageAccess?.(attachments, ref) } - constructor(private readonly dependencies: DeepSeekAdapterOptions) { + constructor(private readonly dependencies: DeepSeekAdapterOptions) { super() this.files = dependencies.resolveFiles?.() ?? new DeepSeekFileStore() } - override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek' } } + override providerInfo(provider: string) { return { id: provider, name: this.dependencies.providerName ?? 'DeepSeek' } } override providerRetryPolicy(_provider: string) { return this.dependencies.options().retryPolicy } override async listModels(provider: string) { - const connection = this.dependencies.options() - try { await this.dependencies.resolveApiKey(connection) } - catch (error) { - if (error instanceof LlmError && ['MISSING_CREDENTIAL', 'ACCOUNT_SIGN_IN_REQUIRED'].includes(error.code)) return [] - throw error - } - return connection.models.map(model => catalogModelInfo(provider, model)) + return this.dependencies.discoverModels?.(provider) ?? [] } override resolveModel(provider: string, model: string, _signal?: AbortSignal) { return Promise.resolve(modelInfo(this.dependencies.options(), provider, model)) @@ -54,7 +48,7 @@ export class DeepSeekAdapter extends LlmAdapter { return this.generate(options, this.dependencies.options()) } - private async * generate(options: GenerateOptions, connection: Connection): AsyncGenerator { + private async * generate(options: GenerateOptions, connection: C): AsyncGenerator { const consumer = new AbortController() const signal = options.signal === undefined ? consumer.signal : AbortSignal.any([consumer.signal, options.signal]) using watchdog = idleWatchdog(signal, connection.streamIdleTimeoutMs, 'MESSAGES_IDLE') @@ -79,7 +73,7 @@ export class DeepSeekAdapter extends LlmAdapter { } private async * request( - options: GenerateOptions, connection: Connection, signal: AbortSignal, activity: () => void, + options: GenerateOptions, connection: C, signal: AbortSignal, activity: () => void, ): AsyncGenerator { signal.throwIfAborted() const { messages, versions } = await prepareImages( @@ -139,8 +133,7 @@ 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) - const code = accountToken !== undefined && response.status === 401 ? 'ACCOUNT_TOKEN_INVALID' : failure.code - throw new LlmError(message, code, { ...failure.failure, cause: new Error(text) }) + throw new LlmError(message, failure.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') @@ -148,10 +141,11 @@ export class DeepSeekAdapter extends LlmAdapter { return } } catch (error) { - if (accountToken !== undefined && error instanceof LlmError && error.code === 'ACCOUNT_TOKEN_INVALID') { - try { await this.dependencies.onInvalidAccountToken?.(accountToken) } catch (_credentialRemovalFailed) { - // Credential storage failure cannot replace the inference failure. - } + if (this.dependencies.onRequestError !== undefined) { + let mapped: unknown + try { mapped = await this.dependencies.onRequestError(error, key) } + catch (_credentialUpdateFailed) { throw error } + throw mapped } throw error } diff --git a/packages/llm/llm-deepseek/src/config.ts b/packages/llm/llm-deepseek/src/config.ts index f2ba3bd23a..fa9d385b87 100644 --- a/packages/llm/llm-deepseek/src/config.ts +++ b/packages/llm/llm-deepseek/src/config.ts @@ -5,7 +5,6 @@ import z from '@deepseek-ai/schemastery' import { isVolatile } from '@deepseek-ai/cosmokit' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts' @@ -13,21 +12,10 @@ import { DEFAULT_MODELS } from './models.ts' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILES_API_TIMEOUT_MS } from './defaults.ts' import { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES } from './request-pricing.ts' -const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' - const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[] -/** - * Plugin config, validated by the same-named schemastery schema and doubling - * as the `llm-deepseek` settings-section shape. Every field is optional in - * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each - * request (a request without any key fails with `MISSING_CREDENTIAL`, not at - * plugin load), omitted thinking mode uses the provider default, and omitted - * reasoning effort resolves to `high`. - */ +/** Shared Messages request configuration, without provider credential selection. */ export interface Config { - /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv: Volatile /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ baseURL: Volatile /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ @@ -89,8 +77,8 @@ const catalogModel: z = z.object({ systemPromptUpdate: z.const('in-history'), }) -export const Config = z.object({ - apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV).volatile(), +/** Shared schema fields for Messages protocol options. */ +export const deepSeekConfigFields = { baseURL: z.string().volatile(), thinking: z.union(['enabled', 'disabled']).volatile(), reasoningEffort: z.union(['off', 'low', 'high', 'max']).volatile(), @@ -109,7 +97,9 @@ export const Config = z.object({ fileRefreshMarginSeconds: z.number().step(1).min(0).default(DEFAULT_FILE_REFRESH_MARGIN_SECONDS).volatile(), fileQuotaCleanupBatch: z.number().step(1).min(1).max(1_000).default(DEFAULT_FILE_QUOTA_CLEANUP_BATCH).volatile(), retryPolicy: RetryPolicySchema.volatile(), -}) +} + +export const Config = z.object(deepSeekConfigFields) /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ export const PUBLIC_BASE_URL = 'https://api.deepseek.com/anthropic' @@ -117,12 +107,7 @@ export const PUBLIC_BASE_URL = 'https://api.deepseek.com/anthropic' /** Environment variable naming this provider's endpoint, honored only from trusted layers. */ const BASE_URL_ENV = 'DEEPSEEK_BASE_URL' -/** - * One resolution's complete request facts. Connection and credential facts - * are one value on purpose: a snapshot the resolver rejects keeps the whole - * previous generation, so a request can never pair a stale endpoint with a - * newer key. - */ +/** Complete protocol settings captured for one request operation. */ export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions /** Resolve, validate, and detach the advisory model catalog. */ @@ -199,8 +184,8 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee } /** - * The one explicit resolve step from raw config to validated connection - * facts. Programmatic construction may bypass Schemastery normalization, so + * The one explicit resolve step from raw config to validated protocol + * settings. Programmatic construction may bypass Schemastery normalization, so * every default and bound is re-judged here — for the composition entry at * load (fail loud) and for each settings snapshot at its first use. * @param config - raw plugin config or resolved settings snapshot. @@ -208,7 +193,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee * the product CLI. Every layer may supply an endpoint: the product trusts the * project it is launched in, so a checkout can point its own agent at the * gateway that checkout is meant to use. - * @returns validated connection facts plus the credential reference. + * @returns validated protocol settings. */ export function resolveAdapterOptions(config: Options, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions { // Settings updates can reach this resolver without schema validation. @@ -302,7 +287,6 @@ export function resolveAdapterOptions(config: Options, environment?: LaunchEnvir throw new Error('llm-deepseek: Messages baseURL must be an HTTP(S) root without credentials, query, or fragment') } return { - apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL, defaults: { thinking: config.thinking, diff --git a/packages/llm/llm-deepseek/src/host.ts b/packages/llm/llm-deepseek/src/host.ts new file mode 100644 index 0000000000..2149472ba8 --- /dev/null +++ b/packages/llm/llm-deepseek/src/host.ts @@ -0,0 +1,46 @@ +/** Shared Host wiring for the DeepSeek protocol adapter. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-settings' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import type {} from '@deepseek-ai/dsh-fs' +import { resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' +import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { deepEqualJson } from '@deepseek-ai/dsh-util-values' +import { DeepSeekAdapter } from './adapter.ts' +import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions } from './types.ts' + +/** + * Register one provider with request-local transport services and live retry policy. + * @param ctx - provider plugin lifetime with the LLM registry injected. + * @param provider - exact route owned by this plugin. + * @param dependencies - provider-owned discovery, credential, and configuration callbacks. + */ +export function registerDeepSeekProvider( + ctx: Context, provider: string, dependencies: Pick, + 'options' | 'resolveApiKey' | 'accountCredential' | 'onRequestError' | 'providerName' | 'discoverModels'>): void { + ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) + let userId: AnonymousUserId | undefined + const adapter = new DeepSeekAdapter({ + ...dependencies, + resolveUserId: () => userId ??= getOrCreateAnonymousUserId(), + onReplayDegrade: ({ provider, model, reason }) => { + ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`) + }, + resolveAttachments: () => ctx.get('attachments'), + resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( + attachments, hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), ref, + ), + prepareExtensions: request => ctx.get('deepseekLlmApiExtensions')?.prepare(request) + ?? Promise.resolve({ fields: {}, accept: () => Promise.resolve() }), + }) + const registration = ctx.llm.registerAdapter([provider], adapter) + let registeredPolicy = dependencies.options().retryPolicy + ctx.on('loader/volatile-update', () => { + let policy: typeof registeredPolicy + try { policy = dependencies.options().retryPolicy } + catch (error) { ctx.logger.warn(error); return } + if (deepEqualJson(policy, registeredPolicy)) return + registration.replace([provider]) + registeredPolicy = policy + }) +} diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 34a2581ece..89a1cce2ca 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,19 +1,5 @@ -/** Register DeepSeek Messages with live configuration and request-local credentials. */ -import type {} from '@deepseek-ai/dsh-settings' -import type {} from '@deepseek-ai/dsh-deepseek-account' -import type {} from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/cordis-plugin-loader' -import type { Context } from '@deepseek-ai/cordis' -import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-fs' -import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' -import { deepEqualJson } from '@deepseek-ai/dsh-util-values' -import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { DeepSeekAdapter } from './adapter.ts' -import { Config, plainOptions, resolveAdapterOptions } from './config.ts' -import type { ResolvedDeepSeekOptions } from './config.ts' - -export { Config, plainOptions, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts' +/** DeepSeek Messages transport, request configuration, and model capabilities. */ +export { deepSeekConfigFields, Config, plainOptions, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts' export type { Options, ResolvedDeepSeekOptions } from './config.ts' export { DEFAULT_CONTEXT_WINDOW, @@ -51,104 +37,5 @@ export { DeepSeekUploadIndex, deepSeekFileScope } from './upload-index.ts' export type { DeepSeekUploadRecord } from './upload-index.ts' export type { RequestDefaults } from './types.ts' -export const name = 'llm-deepseek' -export const inject = ['llm'] - -const NS = 'llm-deepseek' -const PROVIDER = 'deepseek-official' - -export function apply(ctx: Context, config: Config): void { - ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) - const options = (): ResolvedDeepSeekOptions => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx)) - options() - - const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { - // Every credential fact comes from the caller's snapshot, so a rejected - // settings generation cannot leak its key onto the previous endpoint. - const ref = connection.apiKeyEnv - const credentials = ctx.get('credentials') - if (credentials !== undefined) { - const hit = await credentials.resolve(ref) - if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref) - } else { - // Without the seam there is no managed store to rank against, so the - // environment is the whole credential plane. - const ambient = launchEnvironmentOf(ctx).get(ref) - if (ambient !== undefined && ambient.value.length > 0) { - return assertUsableApiKey(ambient.value, 'llm-deepseek', ref) - } - } - throw new LlmError( - `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` - + ` service (the web Models page writes it), or export ${ref} in the launching environment`, - 'MISSING_CREDENTIAL', - ) - } - - let userId: AnonymousUserId | undefined - const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() - const adapter = (accountCredential: boolean): DeepSeekAdapter => new DeepSeekAdapter({ - accountCredential, - ...accountCredential ? { onInvalidAccountToken: async (token: string) => { await ctx.get('deepseekAccount')?.rejectToken(token) } } : {}, - options, - onReplayDegrade: ({ provider, model, reason }) => { - ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`) - }, - resolveApiKey: accountCredential ? async (connection) => { - const token = await ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) - if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED') - return token - } : resolveApiKey, - resolveUserId, - resolveAttachments: () => ctx.get('attachments'), - resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( - attachments, - hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), - ref, - ), - prepareExtensions: (request) => { - const extensions = ctx.get('deepseekLlmApiExtensions') - return extensions?.prepare(request) - ?? Promise.resolve({ fields: {}, accept: () => Promise.resolve() }) - }, - }) - ctx.llm.registerConfigurableProviders([ - { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: ctx.fiber.entry?.options.id ?? NS, settingsPath: [] }, - { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: ctx.fiber.entry?.options.id ?? NS, settingsPath: [] }, - ]) - // Route effects bind to this apply fiber via the stable `ctx` reference, - // even when a swap runs inside the scoped settings callback below. - const registration = ctx.llm.registerAdapter([PROVIDER], adapter(false)) - const accountRegistration = ctx.llm.registerAdapter(['deepseek-account'], adapter(true)) - ctx.inject(['agents'], (agentCtx) => { - agentCtx.on('deepseek-account/signed-out', () => { - for (const agent of agentCtx.agents.list()) { - if (agent.activeProvider === 'deepseek-account') { - agent.cancel({ kind: 'hook', reason: 'deepseek-account/signed-out' }, { keepInbox: true }) - } - } - }) - }) - let registeredPolicy = options().retryPolicy - const ensureRegistrationFacts = (): void => { - let policy: ResolvedDeepSeekOptions['retryPolicy'] - try { - policy = options().retryPolicy - } catch (error) { - // A stored config the resolver refuses keeps the current registration; each request fails on its own resolve. - ctx.logger.warn(error) - return - } - if (deepEqualJson(policy, registeredPolicy)) return - // The registry captures the retry policy at registration, so it is the one - // fact per-request resolution cannot refresh. `replace` re-reads it in one - // synchronous registry section: disposing and re-registering instead would - // publish an empty route set between the two, and an observer that reacted - // to it would see this provider disappear and come back. - registration.replace([PROVIDER]) - accountRegistration.replace(['deepseek-account']) - registeredPolicy = policy - } - - ctx.on('loader/volatile-update', ensureRegistrationFacts) -} +export { catalogModelInfo } from './model-info.ts' +export { registerDeepSeekProvider } from './host.ts' diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 8beb5030d5..4257db1c45 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -1,7 +1,6 @@ /** Model catalog and request-local dependencies for DeepSeek Messages. */ -import type { ModelModality, SystemPromptUpdate, ResolvedRetryPolicy, ImageAttachmentAccess } from '@deepseek-ai/dsh-llm' +import type { LlmModelInfo, ModelModality, SystemPromptUpdate, ResolvedRetryPolicy, ImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import type { DeepSeekLlmApiExtensionRequest, PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { DeepSeekFileStore, DeepSeekFilePolicy } from './file-store.ts' @@ -45,13 +44,6 @@ export interface DeepSeekCatalogModel { export interface DeepSeekConnectionOptions { /** Messages API root; custom paths remain unchanged. */ baseURL: string - /** - * Credential reference of this same resolution, resolved per request. - * Travelling with the endpoint is the point: a request can never pair one - * generation's URL with another generation's secret. Configuration carries - * only this name — a literal key is not a configuration value. - */ - apiKeyEnv: CredentialRef /** Request defaults applied to every call (thinking mode, effort). */ defaults: RequestDefaults /** Default per-request output cap; explicit request values win. */ @@ -83,20 +75,24 @@ export interface DeepSeekConnectionOptions { } /** Constructor options for {@link DeepSeekAdapter}: the operation-local resolution hooks the plugin owns. */ -export interface DeepSeekAdapterOptions { +export interface DeepSeekAdapterOptions { /** Report unusable native Messages replay metadata without exposing content or signatures. */ onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void - /** Remove a server-rejected account token if it still matches the current login; failures preserve the inference error. */ - onInvalidAccountToken?: (token: string) => Promise + /** Provider label for selectors; omission uses the protocol family name. */ + providerName?: string + /** Provider-owned catalog availability; omission exposes no discovery entries. */ + discoverModels?: (provider: string) => Promise + /** Classify a request failure and update credential state; callback failure preserves the original error. */ + onRequestError?: (error: unknown, credential: string) => Promise /** Current validated connection facts; called once per operation. */ - options: () => DeepSeekConnectionOptions + options: () => Connection /** * Resolve this adapter’s credential for the connection facts of one request. The * snapshot is passed in — never re-read — so the key can only ever come * from the same resolution as the endpoint it is sent to. Throws `LlmError` * `MISSING_CREDENTIAL` for a missing API key; account routes reject unavailable grants. */ - resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise + resolveApiKey: (connection: Connection) => Promise /** Send the resolved credential as a bare DSH account token; never fall back to an API key. */ accountCredential?: boolean /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts index d5dfc1e7fa..9c53e02a08 100644 --- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -1,6 +1,6 @@ /** Account route cancellation uses the real loop, registry, and tool lifecycle. */ import { afterEach, expect, it, vi } from 'vitest' -import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import { installAccountTaskCancellation, type DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, LlmAdapter, type GenerateOptions, type StreamChunk, ToolCallId } from '@deepseek-ai/dsh-llm' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -9,7 +9,8 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import * as DeepSeek from '../src/index.ts' +import * as DeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' +import * as AccountProvider from '@deepseek-ai/dsh-llm-deepseek-account' import { assemble } from './assemble.ts' const contexts: Context[] = [] @@ -22,11 +23,13 @@ afterEach(async () => { async function harness() { const ctx = new Context() contexts.push(ctx) + installAccountTaskCancellation(ctx) for (const plugin of [LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, AgentRegistry]) { await ctx.plugin(plugin) } await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(DeepSeek, {}) + await ctx.plugin(AccountProvider, {}) return ctx } @@ -45,6 +48,7 @@ it('never falls back to an API key while signed out', async () => { contexts.push(ctx) await ctx.plugin(LlmRuntime) await ctx.plugin(DeepSeek, {}) + await ctx.plugin(AccountProvider, {}) const fetch = vi.spyOn(globalThis, 'fetch') const result = await assemble(ctx, { provider: 'deepseek-account', model: 'deepseek-v4-flash', messages: [] }) expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'ACCOUNT_SIGN_IN_REQUIRED' } }) @@ -69,16 +73,16 @@ it.each(['deepseek-account', 'deepseek-official'])('cancels only the active acco } })) send(agent) await toolEntered.promise - expect(agent.activeProvider).toBe(provider) + expect(agent.session.requestContext()?.provider).toBe(provider) agent.inbox.splice('next-turn', Infinity, 0, [createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })]) ctx.emit('deepseek-account/signed-out') // Changing settings cannot reclassify the already running tool call. - expect(agent.activeProvider).toBe(provider) + expect(agent.session.requestContext()?.provider).toBe(provider) expect(agent.inbox.nextTurn).toHaveLength(1) if (provider === 'deepseek-official') agent.cancel({ kind: 'user' }, { keepInbox: true }) release.resolve(undefined) await agent.whenIdle() - expect(agent.activeProvider).toBeUndefined() + expect(agent.status).toBe('idle') expect(agent.inbox.nextTurn).toHaveLength(1) expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'aborted', reason: provider === 'deepseek-account' ? { kind: 'hook', reason: 'deepseek-account/signed-out' } : { kind: 'user' } } }) @@ -116,18 +120,18 @@ it.each(['tool', 'retry'])('replaces the active provider only after the next req }) ctx.tools.register(defineContentToolFixture({ name: 'continue', description: '', parameters: {}, execute: async () => [{ type: 'text', text: 'done' }] })) const agent = await ctx.agentLoop.create(SessionId('switch-provider'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) - expect(agent.activeProvider).toBeUndefined() + expect(agent.session.requestContext()?.provider).toBeUndefined() send(agent) await preparing.promise - expect(agent.activeProvider).toBe('deepseek-account') + expect(agent.session.requestContext()?.provider).toBe('deepseek-account') proceed.resolve(undefined) const signal = await streaming.promise - expect(agent.activeProvider).toBe('deepseek-official') + expect(agent.session.requestContext()?.provider).toBe('deepseek-official') ctx.emit('deepseek-account/signed-out') expect(signal.aborted).toBe(false) agent.cancel({ kind: 'user' }) await agent.whenIdle() - expect(agent.activeProvider).toBeUndefined() + expect(agent.status).toBe('idle') }) it('does not infer a provider while the first request is still being prepared', async () => { @@ -143,7 +147,7 @@ it('does not infer a provider while the first request is still being prepared', const agent = await ctx.agentLoop.create(SessionId('first-preparation'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) send(agent) await preparing.promise - expect(agent.activeProvider).toBeUndefined() + expect(agent.session.requestContext()?.provider).toBeUndefined() ctx.emit('deepseek-account/signed-out') proceed.resolve(undefined) await agent.whenIdle() @@ -159,6 +163,7 @@ it('never borrows an account token for a missing API key', async () => { ctx.provide('deepseekAccount', { resolveToken: (url: string): Promise => resolveToken(url) } as DeepSeekAccount) await ctx.plugin(LlmRuntime) await ctx.plugin(DeepSeek, {}) + await ctx.plugin(AccountProvider, {}) const fetch = vi.spyOn(globalThis, 'fetch') const result = await assemble(ctx, { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [] }) expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) @@ -237,3 +242,48 @@ it('surfaces credential storage failures during catalog discovery', async () => ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => { throw new Error('storage unavailable') } } as DeepSeekAccount) await expect(ctx.llm.listModels('deepseek-account')).rejects.toThrow('storage unavailable') }) + +it('ignores idle account history but uses the last bound route during a new turn preparation', async () => { + const ctx = await harness() + ctx.on('llm/stream', async function* () { + yield { type: 'finish', reason: { kind: 'stop' } } + }) + const agent = await ctx.agentLoop.create(SessionId('previous-account-turn'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + send(agent) + await agent.whenIdle() + const cancel = vi.spyOn(agent, 'cancel') + ctx.emit('deepseek-account/signed-out') + expect(cancel).not.toHaveBeenCalled() + const preparing = Promise.withResolvers() + const proceed = Promise.withResolvers() + ctx.on('agent/request', async (_event, next) => { + const config = await next() + preparing.resolve(undefined) + await proceed.promise + return { ...config, provider: 'deepseek-official' } + }) + try { + send(agent) + await preparing.promise + ctx.emit('deepseek-account/signed-out') + expect(cancel).toHaveBeenCalledExactlyOnceWith( + { kind: 'hook', reason: 'deepseek-account/signed-out' }, { keepInbox: true }, + ) + } finally { + proceed.resolve(undefined) + await agent.whenIdle() + } +}) + +it('records a signed-out request failure and publishes sign-in guidance', async () => { + const ctx = await harness() + const guidance = vi.fn() + ctx.on('deepseek-account/model-sign-in-required', guidance) + const agent = await ctx.agentLoop.create(SessionId('signed-out-send'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + send(agent) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ + reason: { kind: 'error', error: { code: 'ACCOUNT_SIGN_IN_REQUIRED' } }, + }) + expect(guidance).toHaveBeenCalledOnce() +}) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 205279dc13..0a5dd3f8f9 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,3 +1,4 @@ +import * as Protocol from '@deepseek-ai/dsh-llm-deepseek' /** * Real Messages round trips use the official root and require credentials. * System-update checks additionally require DEEPSEEK_IN_HISTORY_MODEL. @@ -18,7 +19,7 @@ import type { Message } from '@deepseek-ai/dsh-llm' import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' -import * as Messages from '../src/index.ts' +import * as Messages from '@deepseek-ai/dsh-llm-deepseek-api-key' import { DeepSeekFilesClient } from '../src/files-api.ts' import { MESSAGES_FILES_BETA } from '../src/messages-api.ts' import { assemble, options, user, sourceModuleLoader } from './helpers.ts' @@ -38,7 +39,7 @@ async function boot(models?: Messages.Options['models']) { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(LlmRuntime) await ctx.plugin(Messages, { - baseURL: Messages.PUBLIC_BASE_URL, + baseURL: Protocol.PUBLIC_BASE_URL, maxTokens: 4096, ...models === undefined ? {} : { models }, }) @@ -75,21 +76,21 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () const uploads: string[] = [] const bodies: string[] = [] const files = new DeepSeekFilesClient({ - baseURL: Messages.PUBLIC_BASE_URL, apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl, + baseURL: Protocol.PUBLIC_BASE_URL, apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl, }) - const ownedFiles = new Set>() + const ownedFiles = new Set>() cleanups.push(async () => { for (const id of ownedFiles) await files.delete(id) }) vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const response = await fetchImpl(input, init) - if (url === `${Messages.PUBLIC_BASE_URL}/v1/files` && init?.method === 'POST' && response.ok) { + if (url === `${Protocol.PUBLIC_BASE_URL}/v1/files` && init?.method === 'POST' && response.ok) { const file = await response.clone().json() as { id: string } uploads.push(file.id) - ownedFiles.add(Messages.DeepSeekFileId(file.id)) + ownedFiles.add(Protocol.DeepSeekFileId(file.id)) } - if (url === `${Messages.PUBLIC_BASE_URL}/v1/messages`) { + if (url === `${Protocol.PUBLIC_BASE_URL}/v1/messages`) { expect(new Headers(init?.headers).get('anthropic-beta')).toBe(MESSAGES_FILES_BETA) if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') bodies.push(init.body) @@ -110,7 +111,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () expect(uploads).toHaveLength(1) expect(bodies).toHaveLength(2) expect(bodies.every(body => body.includes(`"file_id":"${uploads[0]}"`) && !body.includes('"type":"base64"'))).toBe(true) - const fileId = Messages.DeepSeekFileId(uploads[0]!) + const fileId = Protocol.DeepSeekFileId(uploads[0]!) const retrieved = await files.retrieve(fileId) expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes }) expect(retrieved.expiresAt).toBeUndefined() @@ -162,7 +163,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () let requests = 0 vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - if (url !== `${Messages.PUBLIC_BASE_URL}/v1/messages`) return fetchImpl(input, init) + if (url !== `${Protocol.PUBLIC_BASE_URL}/v1/messages`) return fetchImpl(input, init) if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') const body = JSON.parse(init.body) as Record expect(body).toMatchObject({ dsh_plugin_packages: { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 76ef776b8e..96b323587e 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,5 +1,5 @@ /** HTTP lifecycle, routing and optional Cordis services under real composition. */ -import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import { installAccountTaskCancellation, type DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -26,7 +26,8 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { DeepSeekAdapter } from '../src/adapter.ts' import { object } from '../src/replay.ts' import { DeepSeekFileStore } from '../src/file-store.ts' -import * as Messages from '../src/index.ts' +import * as Messages from '@deepseek-ai/dsh-llm-deepseek-api-key' +import * as AccountProvider from '@deepseek-ai/dsh-llm-deepseek-account' import { adapter, assemble, chunks, MODEL, options, prepareExtensions, server, sse, textEvents, user, sourceModuleLoader } from './helpers.ts' const cleanup: (() => Promise)[] = [] @@ -122,9 +123,7 @@ describe('direct Messages HTTP', () => { 'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1', }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } }) expect(llm.providerInfo('deepseek-official')).toEqual({ id: 'deepseek-official', name: 'DeepSeek' }) - expect((await llm.listModels('deepseek-official')).map(model => model.id)).toEqual([ - 'deepseek-flash', 'deepseek-v4-pro', - ]) + expect(await llm.listModels('deepseek-official')).toEqual([]) expect(await llm.resolveModel('deepseek-official', 'deepseek-flash')).toMatchObject({ name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', }) @@ -240,6 +239,18 @@ describe('direct Messages HTTP', () => { await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'TRANSPORT' }) }) + it('preserves the transport failure when a provider error callback rejects', async () => { + vi.stubGlobal('fetch', async () => new Response('Unauthorized', { status: 401 })) + const llm = new DeepSeekAdapter({ + options: () => Messages.resolveAdapterOptions({}), + resolveApiKey: () => Promise.resolve('fixture-key'), + resolveUserId: () => 'fixture-user' as import('@deepseek-ai/dsh-anonymous-user-id').AnonymousUserId, + prepareExtensions, + onRequestError: async () => { throw new Error('credential storage unavailable') }, + }) + await expect(chunks(llm.stream(options()))).rejects.toMatchObject({ code: 'AUTH', failure: { status: 401 } }) + }) + it('rejects a successful response with no readable body', async () => { vi.stubGlobal('fetch', async () => new Response(null, { status: 200 })) await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'EMPTY_RESPONSE' }) @@ -282,7 +293,7 @@ describe('Cordis provider composition', () => { await ctx.plugin(Loader) ctx.loader.builtins.include = Include const modules = new Map([ - ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-llm-deepseek', Messages], + ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-llm-deepseek-api-key', Messages], ['@deepseek-ai/dsh-llm-deepseek-account', AccountProvider], ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], @@ -328,6 +339,7 @@ describe('Cordis provider composition', () => { }) ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', rejectToken: async (_token: string): Promise => { ctx.emit('deepseek-account/signed-out') } } as DeepSeekAccount) + installAccountTaskCancellation(ctx) const agent = await ctx.agentLoop.create(SessionId('inference-account-expiry'), { provider: 'deepseek-account', model: MODEL }) agent.followup(user('hello')) await agent.whenIdle() @@ -405,7 +417,7 @@ describe('Cordis provider composition', () => { it.each([false, true])('continues and resumes sessions after a model capability change, in-history=%s', async (inHistory) => { const { ctx, http } = await boot() - await ctx.settings.update('llm-deepseek', { baseURL: http.url, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) + await ctx.settings.update(Messages.name, { baseURL: http.url, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) let prompt = 'old prompt' ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], @@ -548,7 +560,7 @@ it.each([ resolveToken: (url: string) => Promise.resolve(url === 'https://api.deepseek.com' ? 'account-token' : undefined), } as DeepSeekAccount) await ctx.plugin(LlmRuntime) - await ctx.plugin(Messages, { baseURL }) + await ctx.plugin(expected === 'account-token' ? AccountProvider : Messages, { baseURL }) const request = vi.fn((_input, init) => { const headers = new Headers(init?.headers) expect(headers.has('authorization')).toBe(false) @@ -558,6 +570,6 @@ it.each([ return Promise.resolve(new Response(sse(textEvents), { status: 200 })) }) vi.stubGlobal('fetch', request) - await assemble(ctx.llm.stream(options())) + await assemble(ctx.llm.stream(options({ provider: expected === 'account-token' ? 'deepseek-account' : 'deepseek-official' }))) expect(request).toHaveBeenCalledOnce() }) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index b05969240b..28c3c45f3f 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -16,7 +16,7 @@ import type { import { credentialRef } from '@deepseek-ai/dsh-credentials' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import { liveConfig } from '../../../settings/settings/tests/live-config.ts' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -270,8 +270,8 @@ describe('request-level dynamic configuration', () => { maxDelayMs: 100, jitterRatio: 0.2, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) - expect(observed).toEqual([['deepseek-account', 'deepseek-official'], ['deepseek-official', 'deepseek-account']]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(observed).toEqual([['deepseek-official']]) }) it('fails catalog reads while a stored snapshot fails beyond-schema validation, and recovers on repair', async () => { diff --git a/packages/llm/llm-deepseek/tests/egress.spec.ts b/packages/llm/llm-deepseek/tests/egress.spec.ts index 3a74a50cb8..03b04f8550 100644 --- a/packages/llm/llm-deepseek/tests/egress.spec.ts +++ b/packages/llm/llm-deepseek/tests/egress.spec.ts @@ -38,7 +38,7 @@ import { vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import * as LlmDeepSeek from '../src/index.ts' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' let home: string beforeAll(() => { diff --git a/packages/llm/llm-deepseek/tests/extensions.spec.ts b/packages/llm/llm-deepseek/tests/extensions.spec.ts index 66256ce5df..9d4a1fc17b 100644 --- a/packages/llm/llm-deepseek/tests/extensions.spec.ts +++ b/packages/llm/llm-deepseek/tests/extensions.spec.ts @@ -8,7 +8,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { DeepSeekLlmApiExtensionRequest } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import { SessionId } from '@deepseek-ai/dsh-session' -import * as DeepSeek from '../src/index.ts' +import * as DeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import { assemble, options, sse, textEvents } from './helpers.ts' declare module '@deepseek-ai/dsh-deepseek-llm-api-extensions' { diff --git a/packages/llm/llm-deepseek/tests/fixtures/cordis.yml b/packages/llm/llm-deepseek/tests/fixtures/cordis.yml index a64d7c046c..0d95d810dd 100644 --- a/packages/llm/llm-deepseek/tests/fixtures/cordis.yml +++ b/packages/llm/llm-deepseek/tests/fixtures/cordis.yml @@ -11,7 +11,7 @@ config: path: {{credentials}} watch: false -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DEEPSEEK_API_KEY baseURL: {{endpoint}} @@ -19,3 +19,6 @@ reasoningEffort: high maxTokens: 256000 streamIdleTimeoutMs: 300000 +- name: '@deepseek-ai/dsh-llm-deepseek-account' + config: + baseURL: {{endpoint}} diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 141e53a406..a5e4b213a6 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -18,7 +18,7 @@ import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' import * as DeepSeekPluginPackageInventory from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' import { sourceModuleLoader } from './helpers.ts' @@ -79,7 +79,7 @@ async function loadComposition( ] : [], '- id: llm-deepseek', - " name: '@deepseek-ai/dsh-llm-deepseek'", + " name: '@deepseek-ai/dsh-llm-deepseek-api-key'", ' config:', ` baseURL: ${JSON.stringify(options.baseURL)}`, '', @@ -98,7 +98,7 @@ async function loadComposition( ['@deepseek-ai/dsh-session-log-deepseek', SessionLogDeepSeek], ['@deepseek-ai/dsh-plugin-package-inventory-deepseek', DeepSeekPluginPackageInventory], ['@deepseek-ai/dsh-credentials-local', LocalCredentialProvider], - ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek], + ['@deepseek-ai/dsh-llm-deepseek-api-key', LlmDeepSeek], ]) // The custom importer bypasses Node resolution; mirror the package manifests // a deployed cordis.yml has beside its declared dependencies. @@ -137,7 +137,7 @@ describe('llm-deepseek real dynamic composition', () => { expect(request).not.toHaveProperty('dsh_session_log') expect(request.dsh_plugin_packages.packages).toEqual(expect.arrayContaining([ { name: '@deepseek-ai/dsh-deepseek-llm-api-extensions', version: '0.1.0-rc.8' }, - { name: '@deepseek-ai/dsh-llm-deepseek', version: '0.1.0-rc.8' }, + { name: '@deepseek-ai/dsh-llm-deepseek-api-key', version: '0.1.0-rc.8' }, { name: '@deepseek-ai/dsh-session-log-deepseek', version: '0.1.0-rc.8' }, ])) expect(request.dsh_plugin_packages.version).toBe(1) diff --git a/packages/llm/llm-deepseek/tests/model-info.spec.ts b/packages/llm/llm-deepseek/tests/model-info.spec.ts new file mode 100644 index 0000000000..ab05326db4 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/model-info.spec.ts @@ -0,0 +1,9 @@ +/** Catalog metadata supports embedding callers without deployment normalization. */ +import { expect, it } from 'vitest' +import { catalogModelInfo } from '../src/model-info.ts' + +it('advertises a minimal model declaration as text input with its ID as the label', () => { + expect(catalogModelInfo('deepseek-official', { id: 'embedded-model' })).toEqual({ + provider: 'deepseek-official', id: 'embedded-model', name: 'embedded-model', inputModalities: ['text'], + }) +}) diff --git a/packages/llm/llm-deepseek/tests/prepared-call.spec.ts b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts index e35f4a7a7f..965b50929d 100644 --- a/packages/llm/llm-deepseek/tests/prepared-call.spec.ts +++ b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts @@ -1,8 +1,9 @@ /** Prepared calls retain their endpoint and credential generation. */ import { afterEach, expect, it } from 'vitest' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { Config, DeepSeekAdapter, plainOptions, resolveAdapterOptions } from '../src/index.ts' -import type { DeepSeekConnectionOptions } from '../src/index.ts' +import { DeepSeekAdapter } from '../src/index.ts' +import { Config, plainOptions, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek-api-key' +import type { ResolvedDeepSeekOptions as DeepSeekConnectionOptions } from '@deepseek-ai/dsh-llm-deepseek-api-key' import { assemble, chunks, MODEL, options, server } from './helpers.ts' const close: (() => Promise)[] = [] diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts index 7e9c0e2832..af46352638 100644 --- a/packages/llm/llm-deepseek/tests/runtime.e2e.ts +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -1,3 +1,4 @@ +import * as Protocol from '@deepseek-ai/dsh-llm-deepseek' import { readFileSync } from 'node:fs' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -24,7 +25,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import type { Options as Config } from '@deepseek-ai/dsh-llm-deepseek' import type { WireRequest } from '../src/wire-types.ts' import { assemble, type AssembledResult } from './assemble.ts' @@ -110,7 +111,7 @@ async function harness(model: string, config: Partial = {}) { await ctx.plugin(LlmRuntime) await ctx.plugin(E2eAttachmentStore) await ctx.plugin(LlmDeepSeek, { - baseURL: LlmDeepSeek.PUBLIC_BASE_URL, + baseURL: Protocol.PUBLIC_BASE_URL, ...model === VISION ? { models: [{ id: VISION, inputModalities: ['text', 'image'] }] } : {}, ...config, }) @@ -154,7 +155,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () contexts.push(ctx) await ctx.plugin(LlmRuntime) await ctx.plugin(LocalAttachments) - await ctx.plugin(LlmDeepSeek, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL, maxTokens: 4096 }) + await ctx.plugin(LlmDeepSeek, { baseURL: Protocol.PUBLIC_BASE_URL, maxTokens: 4096 }) const model = 'deepseek-flash' await expect(ctx.llm.resolveModelInfo('deepseek-official', model)).resolves.toMatchObject({ inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', @@ -181,11 +182,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () it.skipIf(!VISION_E2E_ENABLED)('uses the built-in official route to upload, reference, and delete one image', async () => { const key = process.env.DEEPSEEK_API_KEY if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') - const baseURL = LlmDeepSeek.PUBLIC_BASE_URL + const baseURL = Protocol.PUBLIC_BASE_URL const ctx = await harness(VISION, { baseURL }) await ctx.plugin(E2eAttachmentStore) const attachments = ctx.attachments as E2eAttachmentStore - let uploadedFile: LlmDeepSeek.DeepSeekFileIdType | undefined + let uploadedFile: Protocol.DeepSeekFileIdType | undefined const nativeFetch = globalThis.fetch const observedFetch: typeof fetch = async (input, init) => { const response = await nativeFetch(input, init) @@ -193,12 +194,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () const method = init?.method ?? (input instanceof Request ? input.method : 'GET') if (method === 'POST' && url.pathname.endsWith('/files') && response.ok) { const value = await response.clone().json() as { id?: unknown } - if (typeof value.id === 'string') uploadedFile = LlmDeepSeek.DeepSeekFileId(value.id) + if (typeof value.id === 'string') uploadedFile = Protocol.DeepSeekFileId(value.id) } return response } vi.stubGlobal('fetch', observedFetch) - const files = new LlmDeepSeek.DeepSeekFilesClient({ baseURL, apiKey: key }) + const files = new Protocol.DeepSeekFilesClient({ baseURL, apiKey: key }) try { const result = await assemble(ctx, { @@ -233,7 +234,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () await ctx.plugin(DeepSeekLlmApiExtensionRegistry) await ctx.plugin(SessionLogDeepSeek, { enabled: true }) await ctx.plugin(PluginPackageInventoryDeepSeek) - await ctx.plugin(LlmDeepSeek, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL, thinking: 'disabled' }) + await ctx.plugin(LlmDeepSeek, { baseURL: Protocol.PUBLIC_BASE_URL, thinking: 'disabled' }) const session = ctx.sessions.create(SessionId('real-extension-fields')) session.append('turn/start', { turn: 1 }) @@ -263,7 +264,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () contexts.push(ctx) await ctx.plugin(LlmRuntime) await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) - await ctx.plugin(LlmDeepSeek, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL }) + await ctx.plugin(LlmDeepSeek, { baseURL: Protocol.PUBLIC_BASE_URL }) const result = await assemble(ctx, { model: FLASH, diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts index df6b4a5457..2710895838 100644 --- a/packages/llm/llm-deepseek/tests/runtime.spec.ts +++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts @@ -1,3 +1,4 @@ +import * as Protocol from '@deepseek-ai/dsh-llm-deepseek' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -21,7 +22,7 @@ import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/d import { SessionId } from '@deepseek-ai/dsh-session' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { providerError } from '../src/transport.ts' @@ -72,7 +73,7 @@ function noExtensions(): Promise { function adapterOf( config: Partial & { apiKey?: string } = {}, attachments?: AttachmentStore, - files?: LlmDeepSeek.DeepSeekFileStore, + files?: Protocol.DeepSeekFileStore, ): DeepSeekAdapter { const { apiKey, ...rest } = config return new DeepSeekAdapter({ @@ -126,22 +127,22 @@ function attachmentStoreOf( } function fileStoreOf( - implementation: (...args: Parameters) => ReturnType, + implementation: (...args: Parameters) => ReturnType, ) { const ensureUploaded = vi.fn(implementation) const invalidate = vi.fn(() => Promise.resolve()) return { - store: Object.assign(new LlmDeepSeek.DeepSeekFileStore(), { ensureUploaded, invalidate }), + store: Object.assign(new Protocol.DeepSeekFileStore(), { ensureUploaded, invalidate }), ensureUploaded, invalidate, } } -function fileReference(fileId: string): Awaited> { +function fileReference(fileId: string): Awaited> { return { - record: { fileId: LlmDeepSeek.DeepSeekFileId(fileId) }, + record: { fileId: Protocol.DeepSeekFileId(fileId) }, uploaded: true, - } as Awaited> + } as Awaited> } function successfulSseResponse(): Response { @@ -1698,13 +1699,13 @@ describe('plugin registration and config', () => { const fiber = await ctx.plugin(LlmDeepSeek, { baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', displayName: 'DeepSeek', - settingsNs: 'llm-deepseek', + settingsNs: 'llm-deepseek-api-key', settingsPath: [], - }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [] }]) + }]) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) expect(ctx.llm.listConfigurableProviders()).toEqual([]) @@ -1734,7 +1735,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -1875,21 +1876,6 @@ describe('plugin registration and config', () => { ]) }) - it('defaults an adapter-supplied catalog entry to text input', async () => { - const connection = resolveAdapterOptions({ models: [] }) - const adapter = new DeepSeekAdapter({ - options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: noExtensions, - }) - await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ - provider: 'deepseek-official', - id: 'adapter-model', - name: 'adapter-model', - inputModalities: ['text'], - }]) - }) it('advertises configured models without restricting arbitrary request ids', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') @@ -2175,7 +2161,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, {}) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) }) it('loads keyless with an empty catalog and fails requests actionably', async () => { @@ -2183,7 +2169,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(0) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) @@ -2264,15 +2250,14 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, {}) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { const adapter = adapterOf() expect(adapter).toBeInstanceOf(DeepSeekAdapter) - // Direct embedding shares the plugin's one resolve step, so it advertises - // the same default catalog instead of a divergent empty one. - await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(2) + await expect(adapter.listModels('deepseek-official')).resolves.toEqual([]) + await expect(adapter.resolveModel('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({ name: 'DeepSeek-V41-Flash' }) }) it('resolves connection facts and the credential exactly once per stream call', async () => { diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 036468aebb..d7b5af77a3 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -35,9 +35,6 @@ { "path": "../../util/home-paths" }, - { - "path": "../../credentials/credentials" - }, { "path": "../deepseek-llm-api-extensions" }, @@ -58,12 +55,6 @@ }, { "path": "../../settings/settings" - }, - { - "path": "../../credentials/deepseek-account" - }, - { - "path": "../../core/agent" } ] } diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index d582217754..f637aa6406 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/dsh-http-proxy": "workspace:*", "@deepseek-ai/cordis-plugin-loader": "workspace:~", diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 2787a1ab7d..b9152f00aa 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -4,7 +4,7 @@ import LlmRuntime, { createToolResultMessage, createUserMessage, ToolCallId, Rea import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import { assemble, type AssembledResult } from './assemble.ts' /** diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index f55a318c50..19949ab02d 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/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-retry/README.md -README.md: ca8018e1fbfcda7b091c30370a195bfe40c5f1b2 -README.zh.md: ec3ee877ef5ddbee29bc6df6c395ed96d1c2f8da +README.md: f923140df30faa11c419ea34a7d2ba71f4102555 +README.zh.md: f8cc3f21f4ddfe54e070c2d79764000925c4d240 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index ca8018e1fb..f923140df3 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -34,7 +34,7 @@ Choose it when a composition runs the agent loop and wants durable request recov ### Minimal configuration ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DEEPSEEK_API_KEY retryPolicy: diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index ec3ee877ef..f8cc3f21f4 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" ### 最小配置 ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DEEPSEEK_API_KEY retryPolicy: diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 5f66b839e6..02906fb6c0 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-invariants": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-mock-server": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 1f2a3262e4..062f6ad300 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index ae22496f7c..9b050e6c47 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/README.md -README.md: e12ae09bdbd5b651339c7282fd4c6e3c314b7d68 -README.zh.md: 6a901902026a86beb778fdc28be9b8f6a7de3dd8 +README.md: c6697f5570c1af2077585d586e38f024a7b14f16 +README.zh.md: c288c924ffbb15d590f4f2b25c97409ccf80a4bd diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index e12ae09bdb..c6697f5570 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -39,7 +39,7 @@ Mount the service and at least one adapter, then select the provider by name in ```yaml - name: '@deepseek-ai/dsh-llm' -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DEEPSEEK_API_KEY ``` diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 6a90190202..c288c924ff 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" ```yaml - name: '@deepseek-ai/dsh-llm' -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DEEPSEEK_API_KEY ``` diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 8c611bac10..ea68782273 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -276,7 +276,8 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo |---|---|---| | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | no | Additive request-field registry for the official DeepSeek LLM API adapter | | `@deepseek-ai/dsh-llm` | no | Provider-neutral LLM service interface for the DeepSeek Harness | -| `@deepseek-ai/dsh-llm-deepseek` | yes | DeepSeek Messages adapter | +| `@deepseek-ai/dsh-llm-deepseek-account` | yes | DeepSeek account provider authentication and discovery | +| `@deepseek-ai/dsh-llm-deepseek-api-key` | yes | DeepSeek api-key provider authentication and discovery | | `@deepseek-ai/dsh-llm-pi-ai` | yes | pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek) | | `@deepseek-ai/dsh-llm-retry` | yes | Provider-routed LLM request retry policy for the DeepSeek Harness | | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | yes | Active Loader-backed plugin package inventory for official DeepSeek LLM API requests | diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index ce4b998862..7909fd93e7 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -63,7 +63,6 @@ async function harness(): Promise { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index c7775725ba..a34281c1b5 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -30,7 +30,6 @@ function stubAgent(ctx: Context, id: string): Agent { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index dd9581f36b..96d6ebadca 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -35,7 +35,7 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-sdk-protocol": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-sdk-protocol": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index ba0fbe1055..50567c32e7 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -15,7 +15,7 @@ import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import type { SessionId } from '@deepseek-ai/dsh-session' import type SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import type { InitializeParams, InitializeResult, diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index eb74c12243..20a16c741f 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -14,7 +14,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' import { HarnessSdkJsonRpcServer } from '../src/index.ts' @@ -924,7 +924,6 @@ describe('HarnessSdkJsonRpcServer', () => { expect(ctx.get('llm')?.listProviders()).toEqual([ { id: 'deepseek-official', name: 'DeepSeek' }, - { id: 'deepseek-account', name: 'DeepSeek Account' }, ]) await server.shutdown() } finally { diff --git a/packages/sdk/server/tsconfig.json b/packages/sdk/server/tsconfig.json index 8bef96bf44..6257024121 100644 --- a/packages/sdk/server/tsconfig.json +++ b/packages/sdk/server/tsconfig.json @@ -18,7 +18,7 @@ "path": "../../llm/llm" }, { - "path": "../../llm/llm-deepseek" + "path": "../../llm/llm-deepseek-api-key" }, { "path": "../../core/agent" diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index 45ac212aa5..94bb35e865 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-invariants": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-mock-server": "workspace:*", "@deepseek-ai/dsh-message-feedback": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", diff --git a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts index 2a91b33f33..b858bf1259 100644 --- a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts +++ b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts @@ -11,7 +11,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import MessageFeedback from '@deepseek-ai/dsh-message-feedback' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import LlmRuntime, { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import DeepSeekLlmApiExtensions from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import { startMockLlmServer, type MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import * as SessionLogDeepSeek from '../src/index.ts' @@ -41,7 +41,7 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged ['@deepseek-ai/dsh-session-persistence-jsonl', JsonlSessionPersistence], ['@deepseek-ai/dsh-message-feedback', MessageFeedback], ['@deepseek-ai/dsh-llm', LlmRuntime], - ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek], + ['@deepseek-ai/dsh-llm-deepseek-api-key', LlmDeepSeek], ['@deepseek-ai/dsh-deepseek-llm-api-extensions', DeepSeekLlmApiExtensions], ['@deepseek-ai/dsh-session-log-deepseek', SessionLogDeepSeek], ]) @@ -52,7 +52,7 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged ? { config: { root: join(root!, 'sessions'), compression: 'none' } } : name === '@deepseek-ai/dsh-message-feedback' ? { config: { maxNoteBytes: 1024 } } - : name === '@deepseek-ai/dsh-llm-deepseek' + : name === '@deepseek-ai/dsh-llm-deepseek-api-key' ? { config: { baseURL: server!.baseURL } } : name === '@deepseek-ai/dsh-session-log-deepseek' ? { config: { enabled: true } } diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index 9867aef734..713990cf78 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-title": "workspace:*", "@deepseek-ai/dsh-session-title-llm": "workspace:*", diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts index 3b16d7349b..b76e25a67a 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts @@ -2,7 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService from '@deepseek-ai/dsh-session-title' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index a520878b48..ea3688fefb 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -49,7 +49,6 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 5c114c1c81..a79d116556 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -43,7 +43,6 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-bash/tests/background.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts index ed9e4b1e43..eaa02db989 100644 --- a/packages/shell/tool-bash/tests/background.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -351,7 +351,6 @@ describe('foreground commands as jobs', () => { const ctx = await setup() const ownerId = SessionId('promote-owner') const owner: Agent = { - activeProvider: undefined, id: ownerId, options: {}, session: Session.create(ownerId, undefined, { @@ -468,7 +467,6 @@ describe('owned background output', () => { const ctx = await setup() const ownerId = SessionId('background-owner') const owner: Agent = { - activeProvider: undefined, id: ownerId, options: {}, session: Session.create(ownerId, undefined, { diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index d4b1b57168..8641952fb4 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -57,7 +57,6 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index ceea6bdda9..2957c34b54 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -43,7 +43,6 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 5095a8be3b..eb96f032e1 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -203,7 +203,6 @@ describe('owned background output (pwsh)', () => { const { ctx, pwsh } = await setup() const ownerId = SessionId('pwsh-background-owner') const owner: Agent = { - activeProvider: undefined, id: ownerId, options: {}, session: Session.create(ownerId, undefined, { @@ -404,7 +403,6 @@ describe('foreground commands as jobs (pwsh)', () => { const { ctx, pwsh } = await setup() const ownerId = SessionId('pwsh-promote-owner') const owner: Agent = { - activeProvider: undefined, id: ownerId, options: {}, session: Session.create(ownerId, undefined, { diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 15528c1074..7d9fb0d2f0 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -73,7 +73,6 @@ function agentForCwd(cwd: string): Agent { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, @@ -91,7 +90,6 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index ebb7f50ce7..448bfaadc0 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-loader-smoke": "workspace:*", "@deepseek-ai/dsh-sdk-client": "workspace:*", "@deepseek-ai/dsh-sdk-protocol": "workspace:*", diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/child.patch.yml b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/child.patch.yml index bef7a64fc5..4313ac6736 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/child.patch.yml +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/child.patch.yml @@ -1,7 +1,7 @@ # SDK-profile patch for the child runtime in the cwd-inheritance proof. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: system-prompt diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 744fb203c6..9b0f2f99ad 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-invariants": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:*", diff --git a/packages/subagent/subagent-spawn-in-process/tests/harness.ts b/packages/subagent/subagent-spawn-in-process/tests/harness.ts index 03442bfb11..5575a616b9 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/harness.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/harness.ts @@ -6,7 +6,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as Spawn from '../src/index.ts' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' diff --git a/packages/subagent/subagent/tests/archive-admission.spec.ts b/packages/subagent/subagent/tests/archive-admission.spec.ts index 4380f7ef72..c279884a67 100644 --- a/packages/subagent/subagent/tests/archive-admission.spec.ts +++ b/packages/subagent/subagent/tests/archive-admission.spec.ts @@ -29,7 +29,6 @@ async function liveAgent(ctx: Context, session: Session, initial: AgentStatus): const cancels: CancelCall[] = [] let status = initial const agent: LiveAgent = { - activeProvider: undefined, id: session.id, options: {}, session, diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 9904d11c8e..16b7f9ffce 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -56,7 +56,6 @@ function agent(ctx: Context, cwd?: string): Agent { }) return { id, options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx, send: () => {}, @@ -659,7 +658,6 @@ describe('terminal-bash plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, @@ -710,7 +708,6 @@ describe('terminal-bash plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 2053ad89cf..962421ce9c 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -41,7 +41,6 @@ function stubAgent(ctx: Context, rawId: string): Agent { const session = Session.create(id) const agent: Agent = { id, options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index d43654b997..11da341945 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -28,7 +28,6 @@ function stubAgent(ctx: Context, rawId: string): Agent { options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scopeFiber.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index 8279699981..b983872934 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -44,7 +44,6 @@ async function agent(ctx: Context): Promise { const session = Session.create(id) const value: Agent = { id, options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 4595eebd44..e110562551 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -21,7 +21,6 @@ async function fakeAgent(ctx: Context, rawId: string): Promise { const session = Session.create(id) const agent: Agent = { id, options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 5ec74013fe..a1e76b01b3 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -35,7 +35,6 @@ async function agent(ctx: Context): Promise { const session = Session.create(id) const value: Agent = { id, options: {}, session, inbox: unsupportedInbox(), - activeProvider: undefined, status: 'idle', ctx: scope.ctx, followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 848af4a2d5..5620914346 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -455,7 +455,6 @@ describe('dsh-tool-workflow', () => { const engine = ctx.workflowEngine as StubEngine const session = Session.create(SessionId('caller')) const parent: Agent = { - activeProvider: undefined, id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx, send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), @@ -669,7 +668,6 @@ describe('dsh-tool-workflow', () => { await ctx.plugin(toolWorkflow, {}) const session = Session.create(SessionId('caller')) const parent: Agent = { - activeProvider: undefined, id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx, send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), diff --git a/packages/workflow/workflow-ptc/package.json b/packages/workflow/workflow-ptc/package.json index fe776b7bb7..a38efafc0a 100644 --- a/packages/workflow/workflow-ptc/package.json +++ b/packages/workflow/workflow-ptc/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", diff --git a/packages/workflow/workflow-ptc/tests/workflow-ptc.e2e.ts b/packages/workflow/workflow-ptc/tests/workflow-ptc.e2e.ts index a887dc8fc3..9b8e741f1d 100644 --- a/packages/workflow/workflow-ptc/tests/workflow-ptc.e2e.ts +++ b/packages/workflow/workflow-ptc/tests/workflow-ptc.e2e.ts @@ -4,7 +4,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import PtcWorkflowEngine from '../src/index.ts' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28e9409c53..9730ffa74f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,6 +479,12 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-account': + specifier: workspace:* + version: link:../../packages/llm/llm-deepseek-account + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../packages/llm/llm-deepseek-api-key '@deepseek-ai/dsh-llm-mock-server': specifier: workspace:* version: link:../../packages/test-support/llm-mock-server @@ -1165,6 +1171,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:* version: link:../../credentials/credentials + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:* + version: link:../../credentials/deepseek-account '@deepseek-ai/dsh-file-reference': specifier: workspace:* version: link:../../context/file-reference @@ -1271,6 +1280,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:* version: link:../../interaction/commands + '@deepseek-ai/dsh-credentials': + specifier: workspace:* + version: link:../../credentials/credentials '@deepseek-ai/dsh-file-reference': specifier: workspace:* version: link:../../context/file-reference @@ -1313,6 +1325,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:* version: link:../../session/session-title + '@deepseek-ai/dsh-settings': + specifier: workspace:* + version: link:../../settings/settings '@deepseek-ai/dsh-skill': specifier: workspace:* version: link:../../skill/skill @@ -1914,9 +1929,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm - '@deepseek-ai/dsh-llm-deepseek': + '@deepseek-ai/dsh-llm-deepseek-account': specifier: workspace:* - version: link:../../llm/llm-deepseek + version: link:../../llm/llm-deepseek-account + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:* version: link:../../llm/llm-pi-ai @@ -2208,9 +2226,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm - '@deepseek-ai/dsh-llm-deepseek': + '@deepseek-ai/dsh-llm-deepseek-api-key': specifier: workspace:* - version: link:../../llm/llm-deepseek + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-llm-retry': specifier: workspace:* version: link:../../llm/llm-retry @@ -6149,6 +6167,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session @@ -6455,6 +6476,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:* version: link:../../llm/llm-pi-ai @@ -6674,9 +6698,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:* + version: link:../../core/agent '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm packages/credentials/deepseek-account-platform: dependencies: @@ -6961,6 +6991,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-permission-presets': specifier: workspace:* version: link:../../interaction/permission-presets @@ -8123,6 +8156,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-ptc-runtime': specifier: workspace:* version: link:../../ptc-runtime/ptc-runtime @@ -9141,6 +9177,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../llm + '@deepseek-ai/dsh-llm-deepseek-account': + specifier: workspace:* + version: link:../llm-deepseek-account + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../llm-deepseek-api-key '@deepseek-ai/dsh-plugin-package-inventory-deepseek': specifier: workspace:* version: link:../plugin-package-inventory-deepseek @@ -9166,6 +9208,55 @@ importers: specifier: workspace:* version: link:../../core/tools + packages/llm/llm-deepseek-account: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:~ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:* + version: link:../../credentials/deepseek-account + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:* + version: link:../../util/launch-environment + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:* + version: link:../llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../llm-deepseek-api-key + + packages/llm/llm-deepseek-api-key: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:~ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-credentials': + specifier: workspace:* + version: link:../../credentials/credentials + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:* + version: link:../../util/launch-environment + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:* + version: link:../llm-deepseek + packages/llm/llm-pi-ai: dependencies: '@deepseek-ai/dsh-brand': @@ -9211,6 +9302,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../llm-deepseek-api-key '@deepseek-ai/dsh-settings': specifier: workspace:* version: link:../../settings/settings @@ -9257,6 +9351,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../llm-deepseek-api-key '@deepseek-ai/dsh-llm-mock-server': specifier: workspace:* version: link:../../test-support/llm-mock-server @@ -9996,9 +10093,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm - '@deepseek-ai/dsh-llm-deepseek': + '@deepseek-ai/dsh-llm-deepseek-api-key': specifier: workspace:* - version: link:../../llm/llm-deepseek + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-scope': specifier: workspace:* version: link:../../core/scope @@ -10391,6 +10488,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-llm-mock-server': specifier: workspace:* version: link:../../test-support/llm-mock-server @@ -10709,6 +10809,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session @@ -11965,6 +12068,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-loader-smoke': specifier: workspace:* version: link:../../test-support/loader-smoke @@ -12139,6 +12245,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session @@ -13377,6 +13486,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-ptc-runtime': specifier: workspace:* version: link:../../ptc-runtime/ptc-runtime @@ -13618,6 +13730,12 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-account': + specifier: workspace:* + version: link:../../packages/llm/llm-deepseek-account + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../packages/llm/llm-deepseek-api-key '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:* version: link:../../packages/llm/llm-pi-ai diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index dc564d0e78..3083c4ad9f 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -63,6 +63,8 @@ "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-retry": "workspace:*", "@deepseek-ai/dsh-mcp-client": "workspace:*", diff --git a/snapshots/acp/escalation-approved/cordis.snapshot.yml b/snapshots/acp/escalation-approved/cordis.snapshot.yml index 20f54d6390..8089a404a4 100644 --- a/snapshots/acp/escalation-approved/cordis.snapshot.yml +++ b/snapshots/acp/escalation-approved/cordis.snapshot.yml @@ -3,7 +3,7 @@ # raw for fixture harvesting. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: acp diff --git a/snapshots/acp/escalation-approved/cordis.yml b/snapshots/acp/escalation-approved/cordis.yml index 07aa7f1760..dc59913c0f 100644 --- a/snapshots/acp/escalation-approved/cordis.yml +++ b/snapshots/acp/escalation-approved/cordis.yml @@ -3,7 +3,7 @@ # stdout remains reserved for ACP JSON-RPC. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: thinking: enabled reasoningEffort: max diff --git a/snapshots/acp/image-compaction/cordis.snapshot.yml b/snapshots/acp/image-compaction/cordis.snapshot.yml index 10b4dcb91e..7588dbbf64 100644 --- a/snapshots/acp/image-compaction/cordis.snapshot.yml +++ b/snapshots/acp/image-compaction/cordis.snapshot.yml @@ -3,7 +3,7 @@ # so the strict read_image gate accepts the route and the tool result carries # the durable image block. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: acp diff --git a/snapshots/sdk/account-provider-signout/account-signout.mjs b/snapshots/sdk/account-provider-signout/account-signout.mjs index c468f6022b..7da825a7be 100644 --- a/snapshots/sdk/account-provider-signout/account-signout.mjs +++ b/snapshots/sdk/account-provider-signout/account-signout.mjs @@ -1,4 +1,4 @@ -/** Deterministic transport boundary; cancellation belongs to the shipped DeepSeek plugin. */ +/** Deterministic transport boundary; cancellation belongs to the shipped account provider. */ export const inject = ['llm', 'agents'] export function apply(ctx) { ctx.on('llm/stream', async function* (request, next) { diff --git a/snapshots/sdk/account-provider-signout/cordis.yml b/snapshots/sdk/account-provider-signout/cordis.yml index dc92a814c0..647a6529ea 100644 --- a/snapshots/sdk/account-provider-signout/cordis.yml +++ b/snapshots/sdk/account-provider-signout/cordis.yml @@ -7,8 +7,8 @@ config: includeDefaultRoots: false -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' +- id: llm-deepseek-account + name: '@deepseek-ai/dsh-llm-deepseek-account' config: thinking: enabled reasoningEffort: max diff --git a/snapshots/sdk/bash-tool/cordis.snapshot.yml b/snapshots/sdk/bash-tool/cordis.snapshot.yml index ed8a7c245d..deb245a752 100644 --- a/snapshots/sdk/bash-tool/cordis.snapshot.yml +++ b/snapshots/sdk/bash-tool/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay layer for the ordinary SDK snapshot composition. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/sdk/bash-tool/cordis.yml b/snapshots/sdk/bash-tool/cordis.yml index 20cade7db1..91fab782dc 100644 --- a/snapshots/sdk/bash-tool/cordis.yml +++ b/snapshots/sdk/bash-tool/cordis.yml @@ -6,7 +6,7 @@ includeDefaultRoots: false - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: thinking: enabled reasoningEffort: max diff --git a/snapshots/sdk/persistent-tools/cordis.snapshot.yml b/snapshots/sdk/persistent-tools/cordis.snapshot.yml index 2578e9565a..af19f49e17 100644 --- a/snapshots/sdk/persistent-tools/cordis.snapshot.yml +++ b/snapshots/sdk/persistent-tools/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Replay layer for the TypeScript SDK minimal customization patch. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/sdk/persistent-tools/cordis.yml b/snapshots/sdk/persistent-tools/cordis.yml index a8b572904f..e28f49f0e6 100644 --- a/snapshots/sdk/persistent-tools/cordis.yml +++ b/snapshots/sdk/persistent-tools/cordis.yml @@ -9,7 +9,7 @@ maxOutputChars: 16000 - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: apiKeyEnv: DEEPSEEK_API_KEY streamIdleTimeoutMs: 172800000 diff --git a/snapshots/sdk/serial-created/cordis.snapshot.yml b/snapshots/sdk/serial-created/cordis.snapshot.yml index c5741704cc..8db307073e 100644 --- a/snapshots/sdk/serial-created/cordis.snapshot.yml +++ b/snapshots/sdk/serial-created/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless TypeScript SDK replay patch over the live profile patch. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/sdk/serial-created/cordis.yml b/snapshots/sdk/serial-created/cordis.yml index 1065247b4b..07a010a37d 100644 --- a/snapshots/sdk/serial-created/cordis.yml +++ b/snapshots/sdk/serial-created/cordis.yml @@ -8,7 +8,7 @@ includeDefaultRoots: false - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: thinking: enabled reasoningEffort: max diff --git a/snapshots/sdk/session-title-after-turn/cordis.snapshot.yml b/snapshots/sdk/session-title-after-turn/cordis.snapshot.yml index e823e2de4f..3005765561 100644 --- a/snapshots/sdk/session-title-after-turn/cordis.snapshot.yml +++ b/snapshots/sdk/session-title-after-turn/cordis.snapshot.yml @@ -2,7 +2,7 @@ # the auxiliary route consumes replay.override.json with pacing so its accepted # title commits only after the main turn has closed. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/sdk/subagent-activation-limit/cordis.snapshot.yml b/snapshots/sdk/subagent-activation-limit/cordis.snapshot.yml index 571a3f2e07..5651076b9b 100644 --- a/snapshots/sdk/subagent-activation-limit/cordis.snapshot.yml +++ b/snapshots/sdk/subagent-activation-limit/cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless activation-capacity overlay holds the first child until the parent finishes. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/sdk/subagent-continuable-inheritance/cordis.snapshot.yml b/snapshots/sdk/subagent-continuable-inheritance/cordis.snapshot.yml index 4aa8d9f021..a4adae4330 100644 --- a/snapshots/sdk/subagent-continuable-inheritance/cordis.snapshot.yml +++ b/snapshots/sdk/subagent-continuable-inheritance/cordis.snapshot.yml @@ -2,7 +2,7 @@ # the live adapter with replay and switch the root session to read-only at # creation. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/child.cordis.yml b/snapshots/sdk/subagent-dsh-sdk-diagnostic/child.cordis.yml index c0960cf480..c9ece5531b 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/child.cordis.yml +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/child.cordis.yml @@ -1,7 +1,7 @@ # SDK-profile patch for the deterministic diagnostic child runtime. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-instructions diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/cordis.snapshot.yml b/snapshots/sdk/subagent-dsh-sdk-diagnostic/cordis.snapshot.yml index 26c1761ffb..c0cdad0aee 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/cordis.snapshot.yml +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless parent-model overlay for the DSH SDK diagnostic snapshot. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/cordis.yml b/snapshots/sdk/subagent-dsh-sdk-diagnostic/cordis.yml index 63ea30da95..b0bbb64ccf 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/cordis.yml +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/cordis.yml @@ -6,7 +6,7 @@ includeDefaultRoots: false - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: thinking: enabled reasoningEffort: max diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml index a58d08433d..495fcac746 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml @@ -2,7 +2,7 @@ # a separate SDK child runtime. Both runtimes persist their request headers. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: tool-subagent diff --git a/snapshots/sdk/subagent-send-message/cordis.snapshot.yml b/snapshots/sdk/subagent-send-message/cordis.snapshot.yml index bf528971cf..27de74efd3 100644 --- a/snapshots/sdk/subagent-send-message/cordis.snapshot.yml +++ b/snapshots/sdk/subagent-send-message/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless counterpart to subagent-send-message/cordis.yml: replace the live adapter # with replay and preserve its child and parent scheduling fence. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/sdk/text-turn/cordis.snapshot.yml b/snapshots/sdk/text-turn/cordis.snapshot.yml index 5cda6a0976..f1ad2e5e04 100644 --- a/snapshots/sdk/text-turn/cordis.snapshot.yml +++ b/snapshots/sdk/text-turn/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless TypeScript SDK replay patch over the live profile patch. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/sdk/text-turn/cordis.yml b/snapshots/sdk/text-turn/cordis.yml index c40d286c07..e6f94c1241 100644 --- a/snapshots/sdk/text-turn/cordis.yml +++ b/snapshots/sdk/text-turn/cordis.yml @@ -8,7 +8,7 @@ includeDefaultRoots: false - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: thinking: enabled reasoningEffort: max diff --git a/snapshots/session/agent-instructions/cordis.snapshot.yml b/snapshots/session/agent-instructions/cordis.snapshot.yml index 128ff6f0bd..b302c539cd 100644 --- a/snapshots/session/agent-instructions/cordis.snapshot.yml +++ b/snapshots/session/agent-instructions/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay counterpart of agent-instructions.cordis.yml: apply the # scenario values and model swap to the ACP profile. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/background-confinement-failure/cordis.snapshot.yml b/snapshots/session/background-confinement-failure/cordis.snapshot.yml index cb0b8879f9..c21bbfc4ed 100644 --- a/snapshots/session/background-confinement-failure/cordis.snapshot.yml +++ b/snapshots/session/background-confinement-failure/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/background-job-admission/cordis.snapshot.yml b/snapshots/session/background-job-admission/cordis.snapshot.yml index 0d74b0a43a..e305bcd58c 100644 --- a/snapshots/session/background-job-admission/cordis.snapshot.yml +++ b/snapshots/session/background-job-admission/cordis.snapshot.yml @@ -2,7 +2,7 @@ # DeepSeek adapter with replay while preserving the app's one-task admission # config and the recorded flash route. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/both-mode-turn/cordis.snapshot.yml b/snapshots/session/both-mode-turn/cordis.snapshot.yml index 689dd60c6c..c738034f2c 100644 --- a/snapshots/session/both-mode-turn/cordis.snapshot.yml +++ b/snapshots/session/both-mode-turn/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless both mode combines the runtime/registry changes with the # DeepSeek-to-replay swap in one profile patch. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/browser-use-chrome-devtools-mcp/cordis.snapshot.yml b/snapshots/session/browser-use-chrome-devtools-mcp/cordis.snapshot.yml index 4b781bd2e3..9fa277104d 100644 --- a/snapshots/session/browser-use-chrome-devtools-mcp/cordis.snapshot.yml +++ b/snapshots/session/browser-use-chrome-devtools-mcp/cordis.snapshot.yml @@ -1,5 +1,5 @@ - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/browser-use-playwright-mcp/cordis.snapshot.yml b/snapshots/session/browser-use-playwright-mcp/cordis.snapshot.yml index 4b781bd2e3..9fa277104d 100644 --- a/snapshots/session/browser-use-playwright-mcp/cordis.snapshot.yml +++ b/snapshots/session/browser-use-playwright-mcp/cordis.snapshot.yml @@ -1,5 +1,5 @@ - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/browser-use-stagehand-native/cordis.snapshot.yml b/snapshots/session/browser-use-stagehand-native/cordis.snapshot.yml index 3c9fe0fced..aa8bc30928 100644 --- a/snapshots/session/browser-use-stagehand-native/cordis.snapshot.yml +++ b/snapshots/session/browser-use-stagehand-native/cordis.snapshot.yml @@ -1,5 +1,5 @@ - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/compaction-output-reserve/cordis.snapshot.yml b/snapshots/session/compaction-output-reserve/cordis.snapshot.yml index 26184d5b99..7144fa48e7 100644 --- a/snapshots/session/compaction-output-reserve/cordis.snapshot.yml +++ b/snapshots/session/compaction-output-reserve/cordis.snapshot.yml @@ -2,7 +2,7 @@ # trigger from 8,240 to 7,500, between the history and its condensed replacement. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: session-persistence-jsonl diff --git a/snapshots/session/compaction-recovery/cordis.snapshot.yml b/snapshots/session/compaction-recovery/cordis.snapshot.yml index 40ca292f22..f389be6b5e 100644 --- a/snapshots/session/compaction-recovery/cordis.snapshot.yml +++ b/snapshots/session/compaction-recovery/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless context-overflow replay composition. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: session-persistence-jsonl diff --git a/snapshots/session/compaction-summary-headroom/cordis.snapshot.yml b/snapshots/session/compaction-summary-headroom/cordis.snapshot.yml index cce5f15fad..ede4392dd0 100644 --- a/snapshots/session/compaction-summary-headroom/cordis.snapshot.yml +++ b/snapshots/session/compaction-summary-headroom/cordis.snapshot.yml @@ -2,7 +2,7 @@ # request output reservation lowers the proactive trigger to 7,500 tokens. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: session-persistence-jsonl diff --git a/snapshots/session/computer-use-cua-driver-mcp/cordis.snapshot.yml b/snapshots/session/computer-use-cua-driver-mcp/cordis.snapshot.yml index e0a9ab915d..625341f473 100644 --- a/snapshots/session/computer-use-cua-driver-mcp/cordis.snapshot.yml +++ b/snapshots/session/computer-use-cua-driver-mcp/cordis.snapshot.yml @@ -1,5 +1,5 @@ - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/computer-use-cua-driver-native/cordis.snapshot.yml b/snapshots/session/computer-use-cua-driver-native/cordis.snapshot.yml index f79df74a92..38bc6ee4d0 100644 --- a/snapshots/session/computer-use-cua-driver-native/cordis.snapshot.yml +++ b/snapshots/session/computer-use-cua-driver-native/cordis.snapshot.yml @@ -1,5 +1,5 @@ - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml b/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml index 1119ac8f98..c0d0aaf209 100644 --- a/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml +++ b/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml @@ -1,6 +1,6 @@ # Replay counterpart to advanced.cordis.yml; only the live model is replaced. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml b/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml index 23c2d736c9..90dfa863ed 100644 --- a/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml +++ b/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml index 0ae3966b64..a1725dcedf 100644 --- a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml +++ b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/empty-response-retry/cordis.snapshot.yml b/snapshots/session/empty-response-retry/cordis.snapshot.yml index 148d845b70..d35b9ed225 100644 --- a/snapshots/session/empty-response-retry/cordis.snapshot.yml +++ b/snapshots/session/empty-response-retry/cordis.snapshot.yml @@ -3,7 +3,7 @@ # 1 ms zero-jitter retry policy as the live sibling. The app patch still # restates its whole config for raw JSONL persistence and the recorded model. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/empty-response-retry/cordis.yml b/snapshots/session/empty-response-retry/cordis.yml index 91031aca5d..3fb1775446 100644 --- a/snapshots/session/empty-response-retry/cordis.yml +++ b/snapshots/session/empty-response-retry/cordis.yml @@ -6,7 +6,7 @@ # adapter fields around `retryPolicy`, while the app patch re-pins the recorded # flash model and restates its base fields. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: thinking: enabled reasoningEffort: max diff --git a/snapshots/session/foreground-confinement-timeout/cordis.snapshot.yml b/snapshots/session/foreground-confinement-timeout/cordis.snapshot.yml index 6e43f8a519..34b510348f 100644 --- a/snapshots/session/foreground-confinement-timeout/cordis.snapshot.yml +++ b/snapshots/session/foreground-confinement-timeout/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/fs-glob-sampling/cordis.snapshot.yml b/snapshots/session/fs-glob-sampling/cordis.snapshot.yml index 2a1eacf554..6847eac60a 100644 --- a/snapshots/session/fs-glob-sampling/cordis.snapshot.yml +++ b/snapshots/session/fs-glob-sampling/cordis.snapshot.yml @@ -1,6 +1,6 @@ # Minimal keyless composition: real app, bash, and search tool; replayed model. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/session/fs-glob-sampling/cordis.yml b/snapshots/session/fs-glob-sampling/cordis.yml index 42151991b5..2148acb322 100644 --- a/snapshots/session/fs-glob-sampling/cordis.yml +++ b/snapshots/session/fs-glob-sampling/cordis.yml @@ -1,6 +1,6 @@ # Minimal live counterpart for the glob-sampling snapshot composition. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: models: - id: deepseek-v4-pro diff --git a/snapshots/session/fs-write-overwrite-bounded/cordis.snapshot.yml b/snapshots/session/fs-write-overwrite-bounded/cordis.snapshot.yml index 7562b53466..968ba42a9b 100644 --- a/snapshots/session/fs-write-overwrite-bounded/cordis.snapshot.yml +++ b/snapshots/session/fs-write-overwrite-bounded/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay counterpart to fs-diff-bound.cordis.yml. The ACP and # persistence rows keep the recorded flash model and raw JSONL harvest. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/lsp-definition/cordis.snapshot.yml b/snapshots/session/lsp-definition/cordis.snapshot.yml index 637a92379b..869fbf1856 100644 --- a/snapshots/session/lsp-definition/cordis.snapshot.yml +++ b/snapshots/session/lsp-definition/cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless replay keeps the LSP composition intact and replaces only the model adapter. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/session/model-switch-notice/cordis.snapshot.yml b/snapshots/session/model-switch-notice/cordis.snapshot.yml index 6224aaff4d..d14c711984 100644 --- a/snapshots/session/model-switch-notice/cordis.snapshot.yml +++ b/snapshots/session/model-switch-notice/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay keeps both recorded model routes in the replay catalog and # applies the same second-assembly selection change as the live composition. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/multimodal-spill-ends/cordis.snapshot.yml b/snapshots/session/multimodal-spill-ends/cordis.snapshot.yml index d23a0a1996..2e425a0ac3 100644 --- a/snapshots/session/multimodal-spill-ends/cordis.snapshot.yml +++ b/snapshots/session/multimodal-spill-ends/cordis.snapshot.yml @@ -3,7 +3,7 @@ # so the strict read_image gate accepts the route and the tool result carries # the durable image block. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/office-skills/cordis.snapshot.yml b/snapshots/session/office-skills/cordis.snapshot.yml index fe31218bff..c05428acd3 100644 --- a/snapshots/session/office-skills/cordis.snapshot.yml +++ b/snapshots/session/office-skills/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/parallel-tool-calls/cordis.snapshot.yml b/snapshots/session/parallel-tool-calls/cordis.snapshot.yml index 7554fbdbcd..c08688e52f 100644 --- a/snapshots/session/parallel-tool-calls/cordis.snapshot.yml +++ b/snapshots/session/parallel-tool-calls/cordis.snapshot.yml @@ -2,7 +2,7 @@ # sandboxed filesystem stack already lives in `dsh-base`; the ACP row re-pins # `deepseek-v4-flash` for the recorded corpus. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/partial-landlock-child-failure/cordis.snapshot.yml b/snapshots/session/partial-landlock-child-failure/cordis.snapshot.yml index a0fc75d166..deaae60cef 100644 --- a/snapshots/session/partial-landlock-child-failure/cordis.snapshot.yml +++ b/snapshots/session/partial-landlock-child-failure/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless runner-classification composition: replay authored model turns and # replace the shipping provider with a deterministic process-launch stand-in. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: sandbox diff --git a/snapshots/session/persistent-pwsh-tool-turn/cordis.snapshot.yml b/snapshots/session/persistent-pwsh-tool-turn/cordis.snapshot.yml index d7860768a4..39f14d585c 100644 --- a/snapshots/session/persistent-pwsh-tool-turn/cordis.snapshot.yml +++ b/snapshots/session/persistent-pwsh-tool-turn/cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless replay counterpart to persistent-pwsh.cordis.yml. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/session/persistent-pwsh-tool-turn/cordis.yml b/snapshots/session/persistent-pwsh-tool-turn/cordis.yml index 857a5cac9e..42e4ba61b7 100644 --- a/snapshots/session/persistent-pwsh-tool-turn/cordis.yml +++ b/snapshots/session/persistent-pwsh-tool-turn/cordis.yml @@ -1,6 +1,6 @@ # Minimal live counterpart for the persistent-pwsh-tool-turn snapshot composition. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: models: - id: deepseek-v4-pro diff --git a/snapshots/session/plugin-manager-mcp/cordis.snapshot.yml b/snapshots/session/plugin-manager-mcp/cordis.snapshot.yml index 611f5be77f..1d007659bf 100644 --- a/snapshots/session/plugin-manager-mcp/cordis.snapshot.yml +++ b/snapshots/session/plugin-manager-mcp/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/plugin-manager/cordis.snapshot.yml b/snapshots/session/plugin-manager/cordis.snapshot.yml index 77a0d1d340..906e59afd2 100644 --- a/snapshots/session/plugin-manager/cordis.snapshot.yml +++ b/snapshots/session/plugin-manager/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/product-subagent-both/cordis.snapshot.yml b/snapshots/session/product-subagent-both/cordis.snapshot.yml index 09f3309a66..e637af6adb 100644 --- a/snapshots/session/product-subagent-both/cordis.snapshot.yml +++ b/snapshots/session/product-subagent-both/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless twin of product-subagent-both.cordis.yml: preserve all four named # product tools while replacing only the external model adapter. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/session/product-subagent-codex/cordis.snapshot.yml b/snapshots/session/product-subagent-codex/cordis.snapshot.yml index 13eeadf634..7af611d3a2 100644 --- a/snapshots/session/product-subagent-codex/cordis.snapshot.yml +++ b/snapshots/session/product-subagent-codex/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless twin of product-subagent-codex.cordis.yml: keep both named product # providers and tools while replacing only the external model adapter. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/session/product-subagent-result-diagnostic/cordis.snapshot.yml b/snapshots/session/product-subagent-result-diagnostic/cordis.snapshot.yml index cfef4aeb19..c76b9ffabd 100644 --- a/snapshots/session/product-subagent-result-diagnostic/cordis.snapshot.yml +++ b/snapshots/session/product-subagent-result-diagnostic/cordis.snapshot.yml @@ -21,5 +21,5 @@ maxDepth: provider-managed - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true diff --git a/snapshots/session/provider-cwd/cordis.snapshot.yml b/snapshots/session/provider-cwd/cordis.snapshot.yml index bcddddccf5..49f137c42a 100644 --- a/snapshots/session/provider-cwd/cordis.snapshot.yml +++ b/snapshots/session/provider-cwd/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/ptc-node-read-only/cordis.snapshot.yml b/snapshots/session/ptc-node-read-only/cordis.snapshot.yml index 72ac169e3c..3507d8f17d 100644 --- a/snapshots/session/ptc-node-read-only/cordis.snapshot.yml +++ b/snapshots/session/ptc-node-read-only/cordis.snapshot.yml @@ -5,7 +5,7 @@ name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/ptc-node-workspace/cordis.snapshot.yml b/snapshots/session/ptc-node-workspace/cordis.snapshot.yml index 72ac169e3c..3507d8f17d 100644 --- a/snapshots/session/ptc-node-workspace/cordis.snapshot.yml +++ b/snapshots/session/ptc-node-workspace/cordis.snapshot.yml @@ -5,7 +5,7 @@ name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/ptc-python-turn/cordis.snapshot.yml b/snapshots/session/ptc-python-turn/cordis.snapshot.yml index 6056786bfb..c85aebb13d 100644 --- a/snapshots/session/ptc-python-turn/cordis.snapshot.yml +++ b/snapshots/session/ptc-python-turn/cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless Python PTC composition through the real headless Loader. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/ptc-read-image/cordis.snapshot.yml b/snapshots/session/ptc-read-image/cordis.snapshot.yml index 0eb8095e77..c45744da10 100644 --- a/snapshots/session/ptc-read-image/cordis.snapshot.yml +++ b/snapshots/session/ptc-read-image/cordis.snapshot.yml @@ -2,7 +2,7 @@ # image-capable replay route. The scenario generates its tiny PNG inside the # run_code program, then exercises read_image as a nested dispatch. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/ptc-turn/cordis.snapshot.yml b/snapshots/session/ptc-turn/cordis.snapshot.yml index 72ac169e3c..3507d8f17d 100644 --- a/snapshots/session/ptc-turn/cordis.snapshot.yml +++ b/snapshots/session/ptc-turn/cordis.snapshot.yml @@ -5,7 +5,7 @@ name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/ptc-workspace-context/cordis.snapshot.yml b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml index f8e5fa3228..13552430ca 100644 --- a/snapshots/session/ptc-workspace-context/cordis.snapshot.yml +++ b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml @@ -5,7 +5,7 @@ name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/pty-tools-sandbox-backend/cordis.snapshot.yml b/snapshots/session/pty-tools-sandbox-backend/cordis.snapshot.yml index bc1250dc13..a912d7ffbd 100644 --- a/snapshots/session/pty-tools-sandbox-backend/cordis.snapshot.yml +++ b/snapshots/session/pty-tools-sandbox-backend/cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless replay counterpart to pty.cordis.yml. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/session/pwsh-tool-turn/cordis.snapshot.yml b/snapshots/session/pwsh-tool-turn/cordis.snapshot.yml index 953fb73924..4751299a1a 100644 --- a/snapshots/session/pwsh-tool-turn/cordis.snapshot.yml +++ b/snapshots/session/pwsh-tool-turn/cordis.snapshot.yml @@ -1,6 +1,6 @@ # Minimal keyless composition: real app, pwsh executor, and pwsh tool; replayed model. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/session/pwsh-tool-turn/cordis.yml b/snapshots/session/pwsh-tool-turn/cordis.yml index 3fbcae1aa4..a0651bcbc7 100644 --- a/snapshots/session/pwsh-tool-turn/cordis.yml +++ b/snapshots/session/pwsh-tool-turn/cordis.yml @@ -1,6 +1,6 @@ # Minimal live counterpart for the pwsh-tool-turn snapshot composition. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: models: - id: deepseek-v4-pro diff --git a/snapshots/session/ralph-loop/cordis.snapshot.yml b/snapshots/session/ralph-loop/cordis.snapshot.yml index e404d8cc99..b06824b579 100644 --- a/snapshots/session/ralph-loop/cordis.snapshot.yml +++ b/snapshots/session/ralph-loop/cordis.snapshot.yml @@ -2,7 +2,7 @@ # `text-turn/cordis.snapshot.yml` applies only to the `default` composition, so # a sibling composition restates those swaps and adds its own rows. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/read-image-text-route/cordis.snapshot.yml b/snapshots/session/read-image-text-route/cordis.snapshot.yml index 019ab3ea82..0952a7d8fa 100644 --- a/snapshots/session/read-image-text-route/cordis.snapshot.yml +++ b/snapshots/session/read-image-text-route/cordis.snapshot.yml @@ -3,7 +3,7 @@ # text-only, so the strict read_image gate refuses and no image ever enters # the durable log. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/read-image/cordis.snapshot.yml b/snapshots/session/read-image/cordis.snapshot.yml index cebeb8b017..0d7618f868 100644 --- a/snapshots/session/read-image/cordis.snapshot.yml +++ b/snapshots/session/read-image/cordis.snapshot.yml @@ -3,7 +3,7 @@ # so the strict read_image gate accepts the route and the tool result carries # the durable image block. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/session-query-spill/cordis.snapshot.yml b/snapshots/session/session-query-spill/cordis.snapshot.yml index 44de6494df..26ec8dfae1 100644 --- a/snapshots/session/session-query-spill/cordis.snapshot.yml +++ b/snapshots/session/session-query-spill/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless session-query snapshot patch: replay plus the filesystem spill # settings inherited by this scenario. The ACP row re-pins the recorded flash model. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/session-reference-spill/cordis.snapshot.yml b/snapshots/session/session-reference-spill/cordis.snapshot.yml index 3a57fbac17..8fc3614dde 100644 --- a/snapshots/session/session-reference-spill/cordis.snapshot.yml +++ b/snapshots/session/session-reference-spill/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/session-sandbox-root/cordis.snapshot.yml b/snapshots/session/session-sandbox-root/cordis.snapshot.yml index b478805d00..7137e28bf0 100644 --- a/snapshots/session/session-sandbox-root/cordis.snapshot.yml +++ b/snapshots/session/session-sandbox-root/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay counterpart of session-sandbox-root.cordis.yml: the replay # swap, recorded model pin, and distinct sandbox fallback form one profile patch. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/subagent-acp-diagnostic/cordis.snapshot.yml b/snapshots/session/subagent-acp-diagnostic/cordis.snapshot.yml index 27db50f97b..6436289dec 100644 --- a/snapshots/session/subagent-acp-diagnostic/cordis.snapshot.yml +++ b/snapshots/session/subagent-acp-diagnostic/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless twin of subagent-acp-diagnostic.cordis.yml: keep the real ACP child # permission-denial path and replace only the external parent model adapter. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/session/subagent-child-question-rejection/cordis.snapshot.yml b/snapshots/session/subagent-child-question-rejection/cordis.snapshot.yml index df623b9b67..d9f970fc20 100644 --- a/snapshots/session/subagent-child-question-rejection/cordis.snapshot.yml +++ b/snapshots/session/subagent-child-question-rejection/cordis.snapshot.yml @@ -2,7 +2,7 @@ # seam, model-facing tool, and tripwire provider while replacing DeepSeek with # per-session replay. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: sandbox diff --git a/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml b/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml index 29adfe77c7..739df0ac6a 100644 --- a/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml +++ b/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless counterpart to depth-two.cordis.yml: apply the depth patch and replace # the live adapter with per-session replay. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: sandbox diff --git a/snapshots/session/subagent-published-run-failure/cordis.snapshot.yml b/snapshots/session/subagent-published-run-failure/cordis.snapshot.yml index 66ae0ec826..8b6169554c 100644 --- a/snapshots/session/subagent-published-run-failure/cordis.snapshot.yml +++ b/snapshots/session/subagent-published-run-failure/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless counterpart to subagent-durability-failure.cordis.yml: replace the # live adapter with replay and fail the provider-owned final child checkpoint. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/subagent-tool-filter/cordis.snapshot.yml b/snapshots/session/subagent-tool-filter/cordis.snapshot.yml index b3ed0b1910..47eb5d850b 100644 --- a/snapshots/session/subagent-tool-filter/cordis.snapshot.yml +++ b/snapshots/session/subagent-tool-filter/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/system-prompt-in-history/cordis.snapshot.yml b/snapshots/session/system-prompt-in-history/cordis.snapshot.yml index 34c9f21ad3..c2a04ad1dd 100644 --- a/snapshots/session/system-prompt-in-history/cordis.snapshot.yml +++ b/snapshots/session/system-prompt-in-history/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay counterpart of system-prompt-in-history/cordis.yml: the # replay route declares the same in-history capability. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: agent-default-model diff --git a/snapshots/session/system-prompt-in-history/cordis.yml b/snapshots/session/system-prompt-in-history/cordis.yml index 51196acfd0..7d0ae94bd1 100644 --- a/snapshots/session/system-prompt-in-history/cordis.yml +++ b/snapshots/session/system-prompt-in-history/cordis.yml @@ -3,7 +3,7 @@ # first read, so the second step appends a `system/message` after the cached # history instead of replacing node 0. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: thinking: enabled reasoningEffort: max diff --git a/snapshots/session/team-targets/cordis.snapshot.yml b/snapshots/session/team-targets/cordis.snapshot.yml index ea5b5dc348..577ac1976c 100644 --- a/snapshots/session/team-targets/cordis.snapshot.yml +++ b/snapshots/session/team-targets/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/text-turn/cordis.snapshot.yml b/snapshots/session/text-turn/cordis.snapshot.yml index 537bc37e4e..4853be171a 100644 --- a/snapshots/session/text-turn/cordis.snapshot.yml +++ b/snapshots/session/text-turn/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/text-turn/cordis.yml b/snapshots/session/text-turn/cordis.yml index 01637104ad..eb2846f1e9 100644 --- a/snapshots/session/text-turn/cordis.yml +++ b/snapshots/session/text-turn/cordis.yml @@ -3,7 +3,7 @@ # deterministic test composition and raw persistence needed for recording. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: thinking: enabled reasoningEffort: max diff --git a/snapshots/session/web-fetch/cordis.snapshot.yml b/snapshots/session/web-fetch/cordis.snapshot.yml index 38768d27a5..59a393d9e0 100644 --- a/snapshots/session/web-fetch/cordis.snapshot.yml +++ b/snapshots/session/web-fetch/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay counterpart: deterministic HTTP remains real; only the model # adapter is replaced by replay. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - insert: diff --git a/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml b/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml index 6062521ff8..c4add94e32 100644 --- a/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml +++ b/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay counterpart: the failed search remains real; only the # conversation model adapter is replaced by replay. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/workflow-confinement/cordis.snapshot.yml b/snapshots/session/workflow-confinement/cordis.snapshot.yml index 568415bf16..90a125674d 100644 --- a/snapshots/session/workflow-confinement/cordis.snapshot.yml +++ b/snapshots/session/workflow-confinement/cordis.snapshot.yml @@ -1,6 +1,6 @@ # Replay the authored model turns through the shipped OS sandbox. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/snapshots/session/workspace-dependencies/cordis.snapshot.yml b/snapshots/session/workspace-dependencies/cordis.snapshot.yml index 6b88b3b3fe..cdaa33eef3 100644 --- a/snapshots/session/workspace-dependencies/cordis.snapshot.yml +++ b/snapshots/session/workspace-dependencies/cordis.snapshot.yml @@ -2,7 +2,7 @@ # script comes from the scenario's committed session JSONL. - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' + name: '@deepseek-ai/dsh-llm-deepseek-api-key' disabled: true - id: plugin-package-inventory-deepseek diff --git a/tsconfig.base.json b/tsconfig.base.json index 8b15b0bb7c..b2078b19cb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -393,6 +393,8 @@ "@deepseek-ai/dsh-llm": ["./packages/llm/llm/src"], "@deepseek-ai/dsh-llm/invariant": ["./packages/llm/llm/src/invariant.ts"], "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm/llm-deepseek/src"], + "@deepseek-ai/dsh-llm-deepseek-account": ["./packages/llm/llm-deepseek-account/src"], + "@deepseek-ai/dsh-llm-deepseek-api-key": ["./packages/llm/llm-deepseek-api-key/src"], "@deepseek-ai/dsh-llm-mock-server": ["./packages/test-support/llm-mock-server/src"], "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm/llm-pi-ai/src"], "@deepseek-ai/dsh-llm-replay": ["./packages/test-support/llm-replay/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index c551469419..67e86a3115 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -343,6 +343,8 @@ { "path": "./packages/experimental/ptc-runtime-python" }, { "path": "./packages/ptc-runtime/ptc-runtime-node" }, { "path": "./packages/llm/llm-deepseek" }, + { "path": "./packages/llm/llm-deepseek-api-key" }, + { "path": "./packages/llm/llm-deepseek-account" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/shell/bash-local" }, { "path": "./packages/shell/shell-env" }, From 9e19ca3e452c31cf404aafcda7294f89c3fa1e17 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:06:00 +0800 Subject: [PATCH 19/30] fix(account): limit automatic model selection to account login --- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 7 ---- docs/subsystems/core.zh.md | 7 ---- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 5 +-- docs/subsystems/session.zh.md | 5 +-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/index.ts | 11 +++--- .../tests/session-models.host.spec.ts | 37 ++++++------------- .../ui-settings-account/src/client/index.ts | 2 +- .../tests/initialization.client.spec.ts | 2 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 +- .../client/ui-settings-models/README.zh.md | 4 +- .../src/client/CustomProviderCard.tsx | 1 - .../src/client/ProviderEditor.tsx | 1 - .../src/client/operations.ts | 13 ------- .../tests/components.client.spec.tsx | 25 +++---------- .../tests/model-list-catalog.client.spec.tsx | 1 - .../tests/provider-form.client.spec.tsx | 31 ++++++---------- .../core/agent-default-model/README.i18n.yaml | 4 +- packages/core/agent-default-model/README.md | 2 - .../core/agent-default-model/README.zh.md | 2 - .../core/agent-default-model/src/index.ts | 34 +++-------------- .../tests/agent-default-model.spec.ts | 32 ---------------- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 1 - packages/core/agent-loop/README.zh.md | 1 - packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 1 - packages/core/agent/README.zh.md | 1 - .../extensions/tool-cordis/src/api-catalog.ts | 12 ++---- 34 files changed, 68 insertions(+), 206 deletions(-) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 28bbc0d975..29fbf9d0ae 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: 36fee2dfea5e38229f16ddbca2da567eeca0f2c5 -core.zh.md: c807828249fde64df291ca098922829107515d1d +core.md: aa9af309c03ebedae353139c4017d19f2607dd7d +core.zh.md: b18ff5bba1c0309e9b9dcdf1f3e97adb5895caf6 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 36fee2dfea..aa9af309c0 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -437,13 +437,6 @@ currentSelection(): ModelSelection * @returns fulfillment after the optional profile write settles. */ async saveSelection(next: ModelSelection): Promise - -/** - * Store the initial setup choice only before an explicit profile selection exists. - * @param next - available model belonging to the initialized provider. - * @returns after the initial choice is saved or an existing choice is retained. - */ -async initializeSelection(next: ModelSelection): Promise ``` Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index c807828249..b18ff5bba1 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -447,13 +447,6 @@ currentSelection(): ModelSelection * @returns fulfillment after the optional profile write settles. */ async saveSelection(next: ModelSelection): Promise - -/** - * Store the initial setup choice only before an explicit profile selection exists. - * @param next - available model belonging to the initialized provider. - * @returns after the initial choice is saved or an existing choice is retained. - */ -async initializeSelection(next: ModelSelection): Promise ``` Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 40802fb4a8..6f335e3f14 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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 docs/subsystems/session.md -session.md: 8c060b1a34f9f16ebf0ececd28ecdf24e77f497b -session.zh.md: dc26e5a5dc15251bef1fb93cc53ceff2fee55ab6 +session.md: e55ebf703c5bd256198687b868831f1741c30906 +session.zh.md: 4143f2366c3b69395b72e70afa1552245337f8de diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 8c060b1a34..e55ebf703c 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -821,11 +821,10 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Initialize a provider default; account login replaces it only when no provider API key is configured. - * @param provider - the provider whose credential was configured. + * Select the first available account model after login when no provider API key is configured. * @returns after saving the first available model or retaining the existing default. */ -@Remote async initializeDefaultModel(provider: string): Promise +@Remote async initializeDefaultModel(): Promise /** * Describe every currently routable model for Host-generation selectors. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index dc26e5a5dc..4143f2366c 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -825,11 +825,10 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Initialize a provider default; account login replaces it only when no provider API key is configured. - * @param provider - the provider whose credential was configured. + * Select the first available account model after login when no provider API key is configured. * @returns after saving the first available model or retaining the existing default. */ -@Remote async initializeDefaultModel(provider: string): Promise +@Remote async initializeDefaultModel(): Promise /** * Describe every currently routable model for Host-generation selectors. diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index ac8eca4d71..74b7d3d75a 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: abe79ff7b2b4be6f2b0ba626c77ad9af22fa2f33 -README.zh.md: 7468ed80cb741574a8ab79422ee363af2bbad3e4 +README.md: b3307ce9d6eaab4f5247214531f354228b640172 +README.zh.md: 4479d3f60046f0bd724a9b6cf42bcace63da20c5 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index abe79ff7b2..b3307ce9d6 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -78,7 +78,7 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. -GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel(provider)` saves the first available model of an API-key provider only before any user choice exists. Account login instead replaces the default when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. +GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel()` saves the first available account model after login when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 7468ed80cb..4479d3f600 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -78,7 +78,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 -GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存 API key 提供方的第一个可用模型。账号登录则在其他提供方均未配置 API key 时替换默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。 +GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel()` 在账号登录后、其他提供方均未配置 API key 时,将第一个可用账号模型保存为默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。 ----- diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b5725bd715..86907feb76 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -285,13 +285,13 @@ export class SessionController extends TypertRemoteService { } /** - * Initialize a provider default; account login replaces it only when no provider API key is configured. - * @param provider - the provider whose credential was configured. + * Select the first available account model after login when no provider API key is configured. * @returns after saving the first available model or retaining the existing default. */ @Remote - async initializeDefaultModel(provider: string): Promise { - if (provider === 'deepseek-account' && await hasProviderApiKey(this.ctx)) return + async initializeDefaultModel(): Promise { + const provider = 'deepseek-account' + if (await hasProviderApiKey(this.ctx)) return const catalog = await buildModelCatalog(this.ctx) const model = catalog.groups.find(group => group.id === provider)?.models[0] if (model === undefined) throw new RemoteError('session/provider-models-unavailable', @@ -299,8 +299,7 @@ export class SessionController extends TypertRemoteService { const selection = { provider, model: model.id, ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }, } - if (provider === 'deepseek-account') await this.ctx.agentDefaultModel.saveSelection(selection) - else await this.ctx.agentDefaultModel.initializeSelection(selection) + await this.ctx.agentDefaultModel.saveSelection(selection) } /** diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 89f087e71f..b0fbf788a1 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -345,26 +345,6 @@ describe('Web session model selection', () => { expect(readImage).toHaveBeenCalledOnce() await ctx.fiber.dispose() }) - it('initializes only the first explicitly configured provider and retains later user choices', async () => { - const { configurationFixture } = await import('../../../settings/settings/tests/configuration-fixture.ts') - const configured = await configurationFixture({ hmr: false }) - const { ctx } = await harness(undefined, configured.ctx) - const controller = createSessionTestController(ctx, { - defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', - }) - await controller.initializeDefaultModel('deepseek-official') - expect(ctx.agentDefaultModel.currentSelection()).toEqual({ - provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high', - }) - await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' }) - await controller.initializeDefaultModel('deepseek-official') - expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' }) - await expect(controller.initializeDefaultModel('empty')).rejects.toMatchObject({ - code: 'session/provider-models-unavailable', details: { provider: 'empty' }, - }) - await ctx.fiber.dispose() - }) - it('checks configured key references even for empty catalogs and skips profiles without keys', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) @@ -417,7 +397,7 @@ describe('Web session model selection', () => { const controller = createSessionTestController(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', }) - await controller.initializeDefaultModel('deepseek-account') + await controller.initializeDefaultModel() expect(describe).toHaveBeenCalledWith('CUSTOM_API_KEY') expect(ctx.agentDefaultModel.currentSelection()).toEqual(configuredKey ? { provider: 'removed', model: 'saved' } @@ -864,18 +844,23 @@ describe('Web session model selection', () => { }) }) -it('initializes a provider without reasoning metadata', async () => { +it('initializes the account model without reasoning metadata and rejects an empty account catalog', async () => { const { configurationFixture } = await import('../../../settings/settings/tests/configuration-fixture.ts') const configured = await configurationFixture({ hmr: false }) const { ctx } = await harness(undefined, configured.ctx) - ctx.llm.registerAdapter(['simple'], new CatalogAdapter('Simple', [ - { provider: 'simple', id: 'basic', name: 'Basic' }, + ctx.provide('credentials', { describe: vi.fn() } as never) + const dispose = ctx.llm.registerAdapter(['deepseek-account'], new CatalogAdapter('Account', [ + { provider: 'deepseek-account', id: 'basic', name: 'Basic' }, ])) const controller = createSessionTestController(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', }) - await controller.initializeDefaultModel('simple') - expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'simple', model: 'basic' }) + await controller.initializeDefaultModel() + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-account', model: 'basic' }) + dispose() + await expect(controller.initializeDefaultModel()).rejects.toMatchObject({ + code: 'session/provider-models-unavailable', details: { provider: 'deepseek-account' }, + }) }) it('reports a non-Error catalog rejection as an unavailable selection', async () => { diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 58d68827d1..006a82bca0 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -78,7 +78,7 @@ export function apply(ctx: Context): void { frame.accept() if (initialize) void (async () => { try { - const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') + const initialized = await ctx.remote.session.initializeDefaultModel() if (!initialized.ok) console.info('[deepseek-account] default model initialization failed', { reason: 'refused' }) } catch (_error) { console.info('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) diff --git a/packages/client/ui-settings-account/tests/initialization.client.spec.ts b/packages/client/ui-settings-account/tests/initialization.client.spec.ts index c4897b80ce..4f3c287799 100644 --- a/packages/client/ui-settings-account/tests/initialization.client.spec.ts +++ b/packages/client/ui-settings-account/tests/initialization.client.spec.ts @@ -58,7 +58,7 @@ it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initialize try { apply(ctx as never) await done.promise - expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account') + expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith() expect(observed).toEqual([ { status: 'credential-stored', failed: false }, { status: 'credential-stored', failed: false }, diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c0cbc1fb05..190e7d9017 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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/client/ui-settings-models/README.md -README.md: 52d07e1cdaa766910dcc2e5f72c989ee20cae860 -README.zh.md: 552ce06ab93c847bd49abe836786521461a2e41a +README.md: aac5663813d831ae8484e2592306e1a7eda5eff6 +README.zh.md: 26fde64f97560c526b35df62161d521193df3a0e diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 52d07e1cda..aac5663813 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Saving credentials or a custom provider completes independently of default-model initialization. Initialization failures are logged; they do not report a successful save as failed or prevent closing the editor. The user can select an available model from the composer. +Saving credentials or a custom provider preserves the selected model. The user can select an available model from the composer. DeepSeek Account appears first and DeepSeek second in the provider list; third-party providers retain their directory order. @@ -59,7 +59,7 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. -The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 552ce06ab9..26fde64f97 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -凭据或自定义提供方保存成功后,不等待默认模型初始化即可完成操作。初始化失败记录日志,不会将已成功的保存报告为失败,也不会阻止编辑器关闭。用户可在 composer 中选择可用模型。 +保存凭据或自定义提供方时保留已选模型。用户可在 composer 中选择可用模型。 提供方列表中 DeepSeek 账号排第一,DeepSeek 排第二;第三方提供方保留目录中的相对顺序。 @@ -59,7 +59,7 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供商」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 -Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。 ----- diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 06bd564bc5..f568ba0103 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -189,7 +189,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // and the retry above now goes straight back to this write. if (stored !== undefined) return stored } - void operations.initializeModel(route.trim()) return undefined } diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 24bc8bc79d..3ed7b42d87 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -298,7 +298,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (keyValue.length > 0) { const stored = await operations.storeCredential(keyRef, keyValue) if (stored !== undefined) return stored - void operations.initializeModel(props.provider) } setKeyDraft('') return undefined diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 071ab72454..8ca6beb798 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -32,11 +32,6 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { - /** - * @param provider - newly configured provider. - * @returns after optional initialization; failures are logged without changing the save outcome. - */ - initializeModel(provider: string): Promise /** * Read one credential reference's state. * @param ref - credential reference name. @@ -86,14 +81,6 @@ export interface ModelsOperations { */ export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { - initializeModel: async (provider) => { - try { - const response = await ctx.remote.session.initializeDefaultModel(provider) - if (!response.ok) console.info('[models] default model initialization failed', { provider, reason: 'refused' }) - } catch (_error) { - console.info('[models] default model initialization failed', { provider, reason: 'disconnected' }) - } - }, describeCredential: async (ref) => { const response = await ctx.remote.credentials.describe([ref]) return response.ok ? response.value[ref] : undefined diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 115d9e88f7..1a2398bd2a 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -1114,27 +1114,14 @@ describe('ModelsSection', () => { expect(baseURL.value).toBe('') }) - it.each(['refused', 'disconnected', 'pending'] as const)('finishes credential saving when default initialization is %s', async (outcome) => { - const log = vi.spyOn(console, 'info').mockImplementation(() => {}) - const pending = Promise.withResolvers() + it('saves credentials without changing the default model', async () => { const { ctx, set } = await mountDeepSeekCard() const initialize = vi.spyOn(ctx.remote.session, 'initializeDefaultModel') - if (outcome === 'pending') initialize.mockReturnValue(pending.promise) - else if (outcome === 'disconnected') initialize.mockRejectedValue(new Error('offline')) - else initialize.mockResolvedValue({ ok: false, - error: new RemoteError('session/provider-models-unavailable', 'empty catalog', { provider: 'deepseek-official' }) }) - try { - fireEvent.change(await screen.findByLabelText(en.keyInput), { target: { value: 'test-key' } }) - fireEvent.click(screen.getByText(en.apply)) - await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() }) - expect(set).toHaveBeenCalledOnce() - expect(initialize).toHaveBeenCalledExactlyOnceWith('deepseek-official') - expect(screen.queryByText('empty catalog')).toBeNull() - } finally { - if (outcome === 'pending') { pending.reject(new Error('closed')); await Promise.resolve() } - initialize.mockRestore() - log.mockRestore() - } + fireEvent.change(await screen.findByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() }) + expect(set).toHaveBeenCalledOnce() + expect(initialize).not.toHaveBeenCalled() }) it('rejects an invalid draft before writing', async () => { diff --git a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx index bf08517f87..a0034f2f7c 100644 --- a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx @@ -12,7 +12,6 @@ function operations(discoverModels: ModelsOperations['discoverModels']): ModelsO return { discoverModels, describeCredential: vi.fn(), - initializeModel: vi.fn(), storeCredential: vi.fn(), removeCredential: vi.fn(), writeSettings: vi.fn(), diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 43590083ae..259114d03a 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -861,28 +861,19 @@ describe('hand-declared providers', () => { return { ...scripted, onClose } } - it('closes a saved custom provider when default initialization is refused', async () => { - const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + it('creates a custom provider without changing the default model', async () => { const { face, mutate, set, onClose } = mountCard() const initialize = vi.spyOn(ctxWith(face).remote.session, 'initializeDefaultModel') - .mockResolvedValue({ ok: false, error: new RemoteError('session/provider-models-unavailable', - 'empty catalog', { provider: 'acme-gateway' }) }) - try { - fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1' } }) - fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'test-key' } }) - fireEvent.click(screen.getByRole('button', { name: en.addModel })) - fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'model' } }) - fireEvent.click(screen.getByText(en.create)) - await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) - expect(mutate).toHaveBeenCalledOnce() - expect(set).toHaveBeenCalledOnce() - expect(initialize).toHaveBeenCalledExactlyOnceWith('acme-gateway') - expect(screen.queryByText('empty catalog')).toBeNull() - } finally { - initialize.mockRestore() - log.mockRestore() - } + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'model' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(mutate).toHaveBeenCalledOnce() + expect(set).toHaveBeenCalledOnce() + expect(initialize).not.toHaveBeenCalled() }) it('writes the whole profile and the key under the derived reference', async () => { diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index eac56801bf..7f2f5c10e8 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/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/core/agent-default-model/README.md -README.md: 2aa3d8ed4e4f36286226f76df4c21dd5cb87be0d -README.zh.md: 7283a44f5f491c1fd51d4c33b6df3f65cb8e76bf +README.md: d98747c457a1065453f88779a8bb75f3219c8e2d +README.zh.md: 9765b1aab7b7d9075ce8e9d94cef27934bf8a1e7 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 2aa3d8ed4e..d98747c457 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -7,8 +7,6 @@ kind: "package-reference" English | [中文](README.zh.md) -`initializeSelection()` serializes the first setup choice with user writes and checks the persisted profile override before writing. It preserves explicit selections, including unavailable models. - ## Summary Give newly created agents a shared default provider and model when their sessions do not specify one. Provider, model, and reasoning effort are live Config fields. Saved selections update the active profile patch and apply to subsequent reads; per-session selection remains owned by the entry point. diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index 7283a44f5f..9765b1aab7 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -7,8 +7,6 @@ kind: "package-reference" [English](README.md) | 中文 -`initializeSelection()` 将首次配置选择与用户写入串行处理,并在写入前检查持久化的 profile override。它保留显式选择,包括暂不可用的模型。 - ## 概述 为会话未指定模型的新 agent 提供共享默认 provider 和模型。Provider、模型和推理强度都是即时 Config 字段。保存的选择更新当前 profile patch,并用于后续读取;会话级选择仍由入口负责。 diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 7947f70110..5c6481a85b 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -52,8 +52,6 @@ export class AgentDefaultModelConfig extends Service { reasoningEffort: z.string().volatile(), }) - private writes: Promise = Promise.resolve() - constructor(private readonly ownerContext: Context, private config: Config) { super(ownerContext, 'agentDefaultModel') @@ -79,32 +77,12 @@ export class AgentDefaultModelConfig extends Service { * @returns fulfillment after the optional profile write settles. */ async saveSelection(next: ModelSelection): Promise { - return this.writeSelection(next, false) - } - - /** - * Store the initial setup choice only before an explicit profile selection exists. - * @param next - available model belonging to the initialized provider. - * @returns after the initial choice is saved or an existing choice is retained. - */ - async initializeSelection(next: ModelSelection): Promise { - return this.writeSelection(next, true) - } - - private writeSelection(next: ModelSelection, initialize: boolean): Promise { - const operation = this.writes.then(async () => { - const entry = this.ownerContext.fiber.entry - const editor = this.ctx.get('configEditor') - if (entry === undefined || editor === undefined) return - const override = editor.configuration().find(row => row.entry === entry)?.override - if (initialize && override !== undefined && ('provider' in override || 'model' in override)) return - await editor.edit(entry, () => ({ - provider: next.provider, model: next.model, - ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, - })) - }) - this.writes = operation.catch(() => { /* A refused write does not block the next user choice. */ }) - return operation + const entry = this.ownerContext.fiber.entry + if (entry === undefined) return + await this.ctx.get('configEditor')?.edit(entry, () => ({ + provider: next.provider, model: next.model, + ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, + })) } } diff --git a/packages/core/agent-default-model/tests/agent-default-model.spec.ts b/packages/core/agent-default-model/tests/agent-default-model.spec.ts index 8543e903c9..256b77b6cc 100644 --- a/packages/core/agent-default-model/tests/agent-default-model.spec.ts +++ b/packages/core/agent-default-model/tests/agent-default-model.spec.ts @@ -31,35 +31,3 @@ it('persists complete selections through its owning profile entry', async () => await standalone.agentDefaultModel.saveSelection({ provider: 'test', model: 'ignored' }) expect(standalone.agentDefaultModel.currentSelection().model).toBe('original') }) - -it('initializes once and retains explicit profile choices after restart', async () => { - const { configurationFixture } = await import('../../../settings/settings/tests/configuration-fixture.ts') - const { ctx, start } = await configurationFixture({ hmr: false }) - const defaults = ctx.agentDefaultModel - await Promise.all([ - defaults.initializeSelection({ provider: 'deepseek-account', model: 'deepseek-flash' }), - defaults.initializeSelection({ provider: 'third-party', model: 'other' }), - ]) - expect(defaults.currentSelection()).toEqual({ provider: 'deepseek-account', model: 'deepseek-flash' }) - await defaults.saveSelection({ provider: 'third-party', model: 'chosen' }) - const restarted = await start() - await restarted.agentDefaultModel.initializeSelection({ provider: 'deepseek-account', model: 'deepseek-flash' }) - expect(restarted.agentDefaultModel.currentSelection()).toEqual({ provider: 'third-party', model: 'chosen' }) -}) - -it('allows initialization after a refused write and retains a model-only override', async () => { - const { configurationFixture } = await import('../../../settings/settings/tests/configuration-fixture.ts') - const { vi } = await import('vitest') - const { ctx, start } = await configurationFixture({ hmr: false }) - const edit = vi.spyOn(ctx.configEditor, 'edit').mockRejectedValueOnce(new Error('write failed')) - onTestFinished(() => { edit.mockRestore() }) - await expect(ctx.agentDefaultModel.saveSelection({ provider: 'test', model: 'refused' })).rejects.toThrow('write failed') - await ctx.agentDefaultModel.initializeSelection({ provider: 'test', model: 'initialized' }) - expect(ctx.agentDefaultModel.currentSelection().model).toBe('initialized') - const restarted = await start() - const rows = restarted.configEditor.configuration().map(row => ({ ...row, override: { model: 'initialized' } })) - const source = vi.spyOn(restarted.configEditor, 'configuration').mockReturnValue(rows) - onTestFinished(() => { source.mockRestore() }) - await restarted.agentDefaultModel.initializeSelection({ provider: 'test', model: 'ignored' }) - expect(restarted.agentDefaultModel.currentSelection().model).toBe('initialized') -}) diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 406ad5af53..28340b515a 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: 07033fb3204e827c5227a99e7211e5e648c389c6 -README.zh.md: 0fce6147b37d704add8266deaf185ea83ae84b73 +README.md: 4c6d3df10cc9ab22a9c00232c9181a96d1829031 +README.zh.md: a3c34e153ea38a4392ba0a272499f59d5492ecaa diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 07033fb320..4c6d3df10c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -25,7 +25,6 @@ English | [中文](README.zh.md) ## Use this package - Mount `dsh-agent-loop` in any composition that should run agents. It supplies the driver behind `ctx.agents` and starts any agents you declare in its config; both [`dsh-base`](../../bundle/base/README.md) and [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.md) mount it as an explicit row. ### Configure declarative agents diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 0fce6147b3..a3c34e153e 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -25,7 +25,6 @@ kind: "package-reference" ## 使用本包 - 在任何应运行 agent 的组合中挂载 `dsh-agent-loop`。它提供 `ctx.agents` 背后的驱动器,并启动你在配置中声明的 agent;[`dsh-base`](../../bundle/base/README.zh.md) 与 [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.zh.md) 都将它作为显式配置行挂载。 ### 配置声明式 agent diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index a5d18afec9..d950c427ea 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/core/agent/README.md -README.md: 17ee5fd723f5322c0ccdfd7cc8443dbb842cf674 -README.zh.md: 94424a5e9bc5a30fbbb5e775658c7b90a37f24f7 +README.md: be003d7b899306b58c4ddc88e720c18dde9ce33b +README.zh.md: e360ba975dddc381e25b69766b1df82f6e45b3b4 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 17ee5fd723..be003d7b89 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -25,7 +25,6 @@ Use `dsh-agent` to create or resume live agents, send follow-up or steering inpu ## Use this package - Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `Agent` handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is `dsh-agent-loop`, so the smallest useful composition loads both. ### Create or resume an agent diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 94424a5e9b..e360ba975d 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -25,7 +25,6 @@ kind: "package-reference" ## 使用本包 - 在存在实时 agent 的任何地方挂载 `dsh-agent`:它提供 `ctx.agents` 以及插件、UI、钩子和编排器所面向编程的 `Agent` 句柄。在没有驱动器注册工厂之前,该服务保持惰性——随附驱动器是 `dsh-agent-loop`,因此最小的可用组合需要同时加载两者。 ### 创建或恢复 agent diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 804a0f362e..e5f05e60be 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -98,12 +98,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'next', description: 'resolved selection accepted by an entry point.' }], returns: 'fulfillment after the optional profile write settles.', }, - { - signature: 'async initializeSelection(next: ModelSelection): Promise', - description: 'Store the initial setup choice only before an explicit profile selection exists.', - parameters: [{ name: 'next', description: 'available model belonging to the initialized provider.' }], - returns: 'after the initial choice is saved or an existing choice is retained.', - }, ], }, { @@ -1832,9 +1826,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the normalized selection installed for the Session.', }, { - signature: '@Remote async initializeDefaultModel(provider: string): Promise', - description: 'Initialize a provider default; account login replaces it only when no provider API key is configured.', - parameters: [{ name: 'provider', description: 'the provider whose credential was configured.' }], + signature: '@Remote async initializeDefaultModel(): Promise', + description: 'Select the first available account model after login when no provider API key is configured.', + parameters: [], returns: 'after saving the first available model or retaining the existing default.', }, { From b8df537f0726db0d255b631424f1cbf2f59b7ba1 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:28:12 +0800 Subject: [PATCH 20/30] fix(desktop): return to initial welcome page after sign-out --- apps/desktop/src/client/WelcomePage.tsx | 3 ++- apps/desktop/src/main.ts | 2 +- .../tests/welcome-renderer.client.spec.tsx | 14 ++++++++++ .../deepseek-account-platform/src/index.ts | 5 +++- .../tests/account.spec.ts | 26 +++++++++++++++++++ 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index b24618a0da..b7f4069bcb 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -10,6 +10,7 @@ type Page = 'entry' | 'key' | 'account' /** * Render the standalone welcome flow using shell-owned operations and localized copy. + * Clearing the account attempt returns the sign-in status page to the initial choices. * @param props.api - isolated preload API; no account credentials reach the renderer. * @returns welcome pages with fixed bottom actions. */ @@ -44,7 +45,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { setAttempt(state.attempt) setStarting(false) setCopyFeedback({ status: 'idle' }) - navigate(state.attempt?.phase === 'cancelled' ? 'entry' : 'account') + navigate(state.attempt === null || state.attempt.phase === 'cancelled' ? 'entry' : 'account') } useEffect(() => { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ca2ed4b204..660241dc79 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -395,7 +395,7 @@ async function main(): Promise { returnedAttempt = attempt.id focusPrimaryWindow() } - if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace({ activate: false }).catch(() => undefined) + if (state.status === 'credential-stored' && attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace({ activate: false }).catch(() => undefined) if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') { void readWelcomeState().then(async (value) => { if (needsWelcome(value) && !quitting) { diff --git a/apps/desktop/tests/welcome-renderer.client.spec.tsx b/apps/desktop/tests/welcome-renderer.client.spec.tsx index 0d3586ecfe..1a39123d19 100644 --- a/apps/desktop/tests/welcome-renderer.client.spec.tsx +++ b/apps/desktop/tests/welcome-renderer.client.spec.tsx @@ -312,3 +312,17 @@ it('ignores a notification received after its renderer unmounts', async () => { await act(async () => { pending.resolve('session-expired') }) expect(screen.queryByRole('alert')).toBeNull() }) + + +it.each(['zh-CN', 'en'])('returns from completed sign-in to the initial page after sign-out: %s', async (language) => { + const view = mount(language) + const publish = view.api.onAccountState.mock.calls[0]![0] + const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } + act(() => { publish({ status: 'credential-stored', links, + attempt: { id: 'completed' as NonNullable['id'], phase: 'succeeded' } }) }) + expect(view.document.querySelector('#auth-page')!.hidden).toBe(false) + act(() => { publish({ status: 'signed-out', links, attempt: null }) }) + expect(view.button('#sign-in').closest('[hidden]')).toBeNull() + expect(view.document.querySelector('#auth-page')!.hidden).toBe(true) + await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}.expected.txt`) +}) diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 0c882a5bab..0a731ff8d6 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -171,9 +171,12 @@ export class PlatformAccount extends DeepSeekAccount { if (record !== undefined && (record.kind !== 'grant' || !grant.safeParse(record.payload).success)) { throw new PlatformAuthError('storage') } + const attempt = this.attempt?.view ?? null return { ...(record === undefined && this.signOutReason !== undefined ? { signOutReason: this.signOutReason } : {}), - status: record === undefined ? 'signed-out' : 'credential-stored', attempt: this.attempt?.view ?? null, + status: record === undefined ? 'signed-out' : 'credential-stored', + // Credential removal can notify watchers before sign-out finishes clearing the attempt. + attempt: record === undefined && attempt?.phase === 'succeeded' ? null : attempt, links: { usageUrl: new URL('/usage', this.origin).href, topUpUrl: new URL('/top_up', this.origin).href }, } } diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index 690195ae0f..c4c6278d1f 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -1389,3 +1389,29 @@ it('ignores an inference rejection while already signed out', async () => { expect(await f.account.getState()).toMatchObject({ status: 'signed-out' }) expect((await f.account.getState()).signOutReason).toBeUndefined() }) + + +it('clears a completed login from snapshots while credential deletion is still settling', async () => { + const f = await fixture() + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + await f.wait('waiting-browser') + await fetch(f.callback(), { redirect: 'manual' }) + await f.wait('succeeded') + const original = f.ctx.credentials.deleteRecord.bind(f.ctx.credentials) + const removed = Promise.withResolvers() + const release = Promise.withResolvers() + const deletion = vi.spyOn(f.ctx.credentials, 'deleteRecord').mockImplementation(async (key) => { + await original(key) + removed.resolve(undefined) + await release.promise + }) + const signingOut = f.account.signOut() + try { + await removed.promise + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', attempt: null }) + } finally { + release.resolve(undefined) + await signingOut + deletion.mockRestore() + } +}) From 5590fbee7c2f22e8c1bc568d10d616f70685017c Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:22:45 +0800 Subject: [PATCH 21/30] fix(account): update sign-in dialog copy --- packages/client/ui-settings-account/src/client/locales.ts | 4 ++-- .../client/ui-settings-account/tests/expected/login-en.txt | 2 +- .../client/ui-settings-account/tests/expected/login-zh.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index 1c8a62192b..2893f9a842 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -3,7 +3,7 @@ export const en = { modelSignInRequired: 'Model unavailable. Please sign in and try again.', sessionExpired: 'You have signed out of your account, please log in again.', close: 'Close', addApiKey: 'Add API Key', retry: 'Sign in again', - loginTitle: 'Start creating', loginDescription: 'Sign in to create, edit, and share your design projects. Everything is saved locally.', + loginTitle: 'Get started', loginDescription: 'Sign in to your DeepSeek account or add an API Key to get started. Your projects and files are stored locally.', browserTitle: 'Waiting for sign in', browserPrompt: 'Page did not open automatically? ', copyLink: 'Copy sign-in link', copiedLink: 'Link copied', copyFailed: 'Copy failed', browserDescription: ', then open it in your browser to finish signing in.', timeoutTitle: 'Sign in timed out', timeoutDescription: 'Sign in again to continue.', @@ -33,7 +33,7 @@ export const zh: Record = { modelSignInRequired: '当前模型暂不可用,请登录后再试', sessionExpired: '登录信息已失效,请重新登录', close: '关闭', addApiKey: '添加 API Key', retry: '重新登录', - loginTitle: '开始你的创作', loginDescription: '登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。', + loginTitle: '开始使用', loginDescription: '登录 DeepSeek 账号,或添加 API Key,即可开始使用。你的项目和文件保存在本地。', browserTitle: '等待登录', browserPrompt: '没有自动打开新页面?', copyLink: '复制登录链接', copiedLink: '链接已复制', copyFailed: '复制失败', browserDescription: ',手动打开登录页完成登录。', timeoutTitle: '登录已超时', timeoutDescription: '请重新登录后继续操作。', diff --git a/packages/client/ui-settings-account/tests/expected/login-en.txt b/packages/client/ui-settings-account/tests/expected/login-en.txt index 342e89587c..ce50b4cb46 100644 --- a/packages/client/ui-settings-account/tests/expected/login-en.txt +++ b/packages/client/ui-settings-account/tests/expected/login-en.txt @@ -1 +1 @@ -Start creatingSign in to create, edit, and share your design projects. Everything is saved locally.Add API KeySign in +Get startedSign in to your DeepSeek account or add an API Key to get started. Your projects and files are stored locally.Add API KeySign in diff --git a/packages/client/ui-settings-account/tests/expected/login-zh.txt b/packages/client/ui-settings-account/tests/expected/login-zh.txt index 5cea375430..f06257b241 100644 --- a/packages/client/ui-settings-account/tests/expected/login-zh.txt +++ b/packages/client/ui-settings-account/tests/expected/login-zh.txt @@ -1 +1 @@ -开始你的创作登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。添加 API Key登录 +开始使用登录 DeepSeek 账号,或添加 API Key,即可开始使用。你的项目和文件保存在本地。添加 API Key登录 From 8e5866e7b06b3a9e7fe4ac3c7ad22a28fc937a62 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:23:39 +0800 Subject: [PATCH 22/30] fix(models): retain API-key provider catalogs without credentials --- packages/llm/llm-deepseek-api-key/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek-api-key/README.md | 4 ++-- packages/llm/llm-deepseek-api-key/README.zh.md | 4 ++-- packages/llm/llm-deepseek-api-key/src/index.ts | 5 ----- .../llm/llm-deepseek-api-key/tests/discovery.spec.ts | 10 ++++++---- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- .../llm/llm-deepseek/tests/account-routing.spec.ts | 6 +++--- packages/llm/llm-deepseek/tests/runtime.spec.ts | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 -- packages/llm/llm-pi-ai/README.zh.md | 2 -- packages/llm/llm-pi-ai/src/adapter.ts | 9 ++------- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 6 ++---- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 2 +- 16 files changed, 27 insertions(+), 41 deletions(-) diff --git a/packages/llm/llm-deepseek-api-key/README.i18n.yaml b/packages/llm/llm-deepseek-api-key/README.i18n.yaml index 90a5acc362..8a9a98f3b0 100644 --- a/packages/llm/llm-deepseek-api-key/README.i18n.yaml +++ b/packages/llm/llm-deepseek-api-key/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-api-key/README.md -README.md: 751e041f7aeba7c5969e54fc9a9e45c0417a9c61 -README.zh.md: 7caeaaaad0a9c8b0e93c27244e61fd7f8a6daaed +README.md: 728747d18b0128c94a1e708eae193def55a5df6e +README.zh.md: 5fb38c82ea7e563e1dea41170384f6f52ea756ee diff --git a/packages/llm/llm-deepseek-api-key/README.md b/packages/llm/llm-deepseek-api-key/README.md index 751e041f7a..728747d18b 100644 --- a/packages/llm/llm-deepseek-api-key/README.md +++ b/packages/llm/llm-deepseek-api-key/README.md @@ -23,7 +23,7 @@ Register authentication and model discovery for `deepseek-official`. This plugin ## Use this package -`apiKeyEnv` defaults to `DEEPSEEK_API_KEY` and resolves per request. When the credentials service exists, its precedence applies; only compositions without that service read the launch environment directly. Missing credentials fail with `MISSING_CREDENTIAL` and yield an empty catalog; malformed credentials fail with `INVALID_CREDENTIAL`. +`apiKeyEnv` defaults to `DEEPSEEK_API_KEY` and resolves per request. When the credentials service exists, its precedence applies; only compositions without that service read the launch environment directly. Requests with missing credentials fail with `MISSING_CREDENTIAL`; malformed credentials fail with `INVALID_CREDENTIAL`. Model discovery returns the configured catalog regardless of credentials. ```yaml - id: llm-deepseek @@ -40,7 +40,7 @@ The endpoint and credential reference come from one configuration resolution. In ## Understand the implementation -Registrations and listeners dispose with the plugin. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route. No invariant companion is published: discovery derives directly from configuration and credentials without an independent copy. +Registrations and listeners dispose with the plugin. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route. No invariant companion is published: discovery derives directly from configuration without an independent copy. ## Further Exploration diff --git a/packages/llm/llm-deepseek-api-key/README.zh.md b/packages/llm/llm-deepseek-api-key/README.zh.md index 7caeaaaad0..5fb38c82ea 100644 --- a/packages/llm/llm-deepseek-api-key/README.zh.md +++ b/packages/llm/llm-deepseek-api-key/README.zh.md @@ -23,7 +23,7 @@ kind: "package-reference" ## 使用此包 -`apiKeyEnv` 默认为 `DEEPSEEK_API_KEY`,在每次请求时解析凭据。已组合 credentials 服务时,按其优先级解析;只有未组合该服务时才直接读取启动环境。凭据缺失以 `MISSING_CREDENTIAL` 失败,模型目录为空;格式错误以 `INVALID_CREDENTIAL` 失败。 +`apiKeyEnv` 默认为 `DEEPSEEK_API_KEY`,在每次请求时解析凭据。已组合 credentials 服务时,按其优先级解析;只有未组合该服务时才直接读取启动环境。请求时凭据缺失以 `MISSING_CREDENTIAL` 失败;格式错误以 `INVALID_CREDENTIAL` 失败。模型发现始终返回配置的目录,不依赖凭据。 ```yaml - id: llm-deepseek @@ -40,7 +40,7 @@ kind: "package-reference" ## 理解实现 -注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。无需 invariant companion:目录直接由同一次配置和凭据读取派生,不维护独立副本。 +注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。无需 invariant companion:目录直接由配置派生,不维护独立副本。 ## 进一步探索 diff --git a/packages/llm/llm-deepseek-api-key/src/index.ts b/packages/llm/llm-deepseek-api-key/src/index.ts index ebcba71b54..128ede2b6f 100644 --- a/packages/llm/llm-deepseek-api-key/src/index.ts +++ b/packages/llm/llm-deepseek-api-key/src/index.ts @@ -40,11 +40,6 @@ export function apply(ctx: Context, config: Config): void { options, resolveApiKey, providerName: 'DeepSeek', discoverModels: async (provider) => { const connection = options() - try { await resolveApiKey(connection) } - catch (error) { - if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return [] - throw error - } return connection.models.map(model => catalogModelInfo(provider, model)) }, }) diff --git a/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts b/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts index 1a55ded9d5..bac1caa441 100644 --- a/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts +++ b/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts @@ -1,16 +1,18 @@ -/** Discovery preserves actionable credential failures. */ +/** The configured model catalog is independent of request credentials. */ import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' import { expect, it, vi } from 'vitest' import * as ApiKey from '../src/index.ts' -it('reports malformed API keys instead of advertising an empty catalog', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'invalid\nheader') +it.each(['', 'invalid\nheader'])('advertises configured models without a usable API key: %j', async (key) => { + vi.stubEnv('DEEPSEEK_API_KEY', key) const ctx = new Context() try { await ctx.plugin(LlmRuntime) await ctx.plugin(ApiKey, {}) - await expect(ctx.llm.listModels('deepseek-official')).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + expect(await ctx.llm.listModels('deepseek-official')).toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: 'deepseek-official', id: 'deepseek-flash' }), + ])) } finally { await ctx.fiber.dispose() vi.unstubAllEnvs() diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 0d8cde2270..6aaafef79d 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: 96fe78a4375cef56814923011c8b7589fe23cf7e -README.zh.md: b63660c30ba1f13a54fd5da2e8360b318bea05c7 +README.md: 5e569f3bf63e0280112861843d00d48516d5e23c +README.zh.md: 9df5b417e33a229ced1fdc059e957204841de01b diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 96fe78a437..5e569f3bf6 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -115,7 +115,7 @@ Successful Files responses must contain valid JSON. JSON decoding failures from Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request on the official route without an API key fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key. -Provider plugins own credential-gated discovery. Their catalogs are configured independently; the transport supplies shared default model metadata and capability resolution. +Provider plugins own catalog availability; only the account route requires a stored grant for discovery. Their catalogs are configured independently; the transport supplies shared default model metadata and capability resolution. ----- diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index b63660c30b..9df5b417e3 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -115,7 +115,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。官方路由缺少 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 -provider 插件拥有按凭据可用性过滤的模型发现。两者的目录独立配置;传输层提供共享的默认模型元数据和能力解析。 +提供方插件负责目录可用性;仅账号路由要求存有凭据才能发现模型。两者的目录独立配置;传输层提供共享的默认模型元数据和能力解析。 ----- diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts index 9c53e02a08..74c0996d50 100644 --- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -220,16 +220,16 @@ it('cancels an account child without stopping its API-key parent', async () => { await parent.whenIdle() }) -it('advertises account and API-key models only while their own credential is available', async () => { +it('hides signed-out account models while retaining the official catalog without an API key', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = await harness() let token: string | undefined ctx.provide('deepseekAccount', { resolveToken: async (url: string) => new URL(url).origin === 'https://api.deepseek.com' ? token : undefined } as DeepSeekAccount) expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) - expect(await ctx.llm.listModels('deepseek-official')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) token = 'fixture-account-token' expect(await ctx.llm.listModels('deepseek-account')).not.toHaveLength(0) - expect(await ctx.llm.listModels('deepseek-official')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-key') expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) token = undefined diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts index 2710895838..447ceabd3e 100644 --- a/packages/llm/llm-deepseek/tests/runtime.spec.ts +++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts @@ -2170,7 +2170,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(0) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) // The guidance leads with the managed credential store. diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 2ad742a6dd..c5fbc218d7 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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-pi-ai/README.md -README.md: c69dc0ca52c27973d7bf157e56b8f18bf39bec00 -README.zh.md: 41b8b7a3e12baba065ed119777382110e5bd05a6 +README.md: 7b1708b957202369382d8a93b2bbcefd31565f67 +README.zh.md: ed780ad235b1d8df2b821be6a0595ecd8b8e951b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index c69dc0ca52..7b1708b957 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -120,8 +120,6 @@ Config updates strictly validate changed providers. Initial loading retains stor Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently. -Model discovery resolves each route’s credential configuration. A missing required credential yields an empty catalog; other resolution errors propagate. Routes with native authentication or no required key retain their configured catalog. This check does not probe the inference endpoint. - ----- diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 41b8b7a3e1..ed780ad235 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -120,8 +120,6 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的 只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。 -模型发现会解析各路由的凭据配置。缺少必需凭据时返回空目录;其他解析错误继续抛出。使用原生认证或无需密钥的路由保留其配置的目录。此检查不会探测推理端点。 - ----- diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 3370e1ffcc..de9f3e0e42 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -274,14 +274,9 @@ export class PiAiAdapter extends LlmAdapter { } override listModels(provider: string): Promise { - return Promise.resolve().then(async () => { + return Promise.resolve().then(() => { const snapshot = this.current() - const profile = this.profileOf(snapshot, provider) - try { await this.config.resolveApiKey(provider, profile) } - catch (error) { - if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return [] - throw error - } + this.profileOf(snapshot, provider) return snapshot.models.getModels(provider).map(model => ({ provider, id: model.id, diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 0fe90c4e58..58b8d6afec 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1039,13 +1039,11 @@ it.each([ new LlmError('missing credential', 'MISSING_CREDENTIAL'), new LlmError('invalid credential', 'INVALID_CREDENTIAL'), new Error('credential storage failed'), -])('distinguishes missing credentials from catalog failures: $message', async (error) => { +])('retains its configured catalog independently of credential failures: $message', async (error) => { const adapter = new PiAiAdapter({ profiles: () => resolveProfiles({ deepseek: { apiKeyEnv: 'PI_TEST_KEY' } }), resolveApiKey: () => Promise.reject(error), auth: memoryAuth(), }) - if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') { - expect(await adapter.listModels('deepseek')).toEqual([]) - } else await expect(adapter.listModels('deepseek')).rejects.toBe(error) + expect(await adapter.listModels('deepseek')).not.toHaveLength(0) }) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 155291f594..e574461018 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -1126,7 +1126,7 @@ describe('resolution snapshots', () => { profiles: () => current, // Credential resolution is the real await inside a stream call, and the // window a configuration change has to land in. - resolveApiKey: async (provider) => { if (provider === 'deepseek') await held; return 'k' }, + resolveApiKey: async () => { await held; return 'k' }, auth: memoryAuth(), }) From 8f180cf45f6c9f686958dcb67afd4d3d8847f20f Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:26:01 +0800 Subject: [PATCH 23/30] fix(account): deliver credential expiry notices without replay --- apps/desktop/src/account-backend.ts | 22 +++++---- apps/desktop/src/main.ts | 13 ++++- apps/desktop/tests/account-backend.spec.ts | 47 +++++++++++++++++-- .../tests/welcome-renderer.client.spec.tsx | 4 +- apps/desktop/tests/welcome-startup.spec.ts | 15 ++++-- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 3 +- docs/event-producer-consumer.zh.md | 3 +- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 17 ++++++- docs/subsystems/credentials.zh.md | 17 ++++++- .../api/account-controller/README.i18n.yaml | 4 +- packages/api/account-controller/README.md | 2 + packages/api/account-controller/README.zh.md | 2 + packages/api/account-controller/src/index.ts | 22 +++++++++ .../tests/controller.spec.ts | 19 +++++++- packages/api/remotes/src/remote-events.ts | 1 + .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 +- .../client/ui-settings-account/README.zh.md | 2 +- .../src/client/AccountMenu.tsx | 6 +-- .../src/client/AccountSection.tsx | 5 ++ .../ui-settings-account/src/client/index.ts | 1 + .../tests/account.client.spec.tsx | 16 +++++-- .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 +- .../deepseek-account-platform/README.zh.md | 2 +- .../deepseek-account-platform/src/index.ts | 9 +--- .../tests/account.spec.ts | 29 ++++++++---- .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 +- .../credentials/deepseek-account/README.zh.md | 2 +- .../credentials/deepseek-account/src/index.ts | 2 +- .../credentials/deepseek-account/src/types.ts | 6 ++- .../extensions/tool-cordis/src/api-catalog.ts | 12 ++++- 35 files changed, 238 insertions(+), 71 deletions(-) diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 7b60e55db7..d816de55fc 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -20,9 +20,6 @@ export function accountView(value: unknown): AccountView { } validateBrowserDestination(value.links.usageUrl) validateBrowserDestination(value.links.topUpUrl) - if ('signOutReason' in value && value.signOutReason !== 'expired') { - throw new Error('desktop account: invalid sign-out reason') - } const attempt = value.attempt if (attempt !== null && (typeof attempt !== 'object' || !('id' in attempt) || typeof attempt.id !== 'string' || !('phase' in attempt) || !['initializing', 'waiting-browser', 'exchanging', 'committing', 'succeeded', 'cancelled', 'expired', 'failed'].includes(String(attempt.phase)) @@ -37,7 +34,6 @@ export function accountView(value: unknown): AccountView { const parsed = value as AccountView return { status: parsed.status, - ...parsed.signOutReason === undefined ? {} : { signOutReason: parsed.signOutReason }, links: { usageUrl: parsed.links.usageUrl, topUpUrl: parsed.links.topUpUrl }, attempt: parsed.attempt === null ? null : { id: parsed.attempt.id, phase: parsed.attempt.phase, @@ -67,8 +63,13 @@ export interface DesktopAccountBackend { cancel(id: SignInAttemptId): Promise /** @returns state after local sign-out. */ signOut(): Promise - /** @param listener - state recipient. @param failed - stream failure recipient. @returns stream disposer. */ - watch(listener: (state: AccountView) => void, failed: () => void): () => void + /** + * @param listener - state recipient. + * @param failed - stream failure recipient. + * @param expired - live credential-expiry recipient. + * @returns stream disposer. + */ + watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void } /** @@ -84,22 +85,27 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo return { state: () => call('getState'), start: locale => call('startSignIn', { locale, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: () => call('signOut'), - watch(listener, failed) { + watch(listener, failed, expired) { let closed = false let socket: WebSocket | undefined let retry: ReturnType | undefined const connect = (): void => { const streamId = randomUUID() + const expiryStreamId = randomUUID() void cookies().then((cookie) => { if (closed) return const url = new URL(REMOTE_STREAM_MUX_PATH, origin) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) - socket.on('open', () => socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))) + socket.on('open', () => { + socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) + socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) + }) socket.on('message', (data) => { try { const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) + if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') if (frame.type === 'item') listener(accountView(frame.value)) else socket?.close() diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 660241dc79..2cc70495a3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -383,7 +383,8 @@ async function main(): Promise { injections = ready.injections welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; ')) stopAccount?.() - stopAccount = welcomeBackend.account.watch((state) => { + const accountBackend = welcomeBackend.account + stopAccount = accountBackend.watch((state) => { if (quitting) return if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state) const attempt = state.attempt @@ -399,7 +400,6 @@ async function main(): Promise { if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') { void readWelcomeState().then(async (value) => { if (needsWelcome(value) && !quitting) { - if (state.signOutReason === 'expired') pendingWelcomeNotice = 'session-expired' enteredWorkspace = false await showWelcome() if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state) @@ -410,6 +410,15 @@ async function main(): Promise { previousAccountStatus = state.status }, () => { // The stream reconnects; a transport failure does not change account state. + }, () => { + void readWelcomeState().then(async (value) => { + if (!needsWelcome(value) || quitting) return + pendingWelcomeNotice = 'session-expired' + enteredWorkspace = false + await showWelcome() + const state = await accountBackend.state() + if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state) + }).catch(() => undefined) }) }, stop: async () => { diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index 11217454ac..48dd4e3133 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -1,4 +1,6 @@ -import { expect, it } from 'vitest' +import { once } from 'node:events' +import { WebSocketServer } from 'ws' +import { expect, it, onTestFinished, vi } from 'vitest' import { accountView, desktopAccountBackend } from '../src/account-backend.ts' it('projects only safe account fields and refuses non-browser authorization schemes', () => { @@ -25,9 +27,44 @@ it('uses account Remote commands without returning additional wire fields', asyn expect(requests).toEqual([{ namespace: 'account', method: 'startSignIn', args: { locale: 'en', callbackOrigin: 'http://127.0.0.1:1234', loginSource: 'desktop' } }]) }) -it('preserves the safe expiration reason and rejects unknown reasons', () => { - const state = { status: 'signed-out', attempt: null, signOutReason: 'expired', +it('does not replay an expiry reason from an account snapshot', () => { + const state = { status: 'signed-out', attempt: null, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } - expect(accountView(state)).toEqual(state) - expect(() => accountView({ ...state, signOutReason: 'unexpected' })).toThrow('invalid sign-out reason') + expect(accountView({ ...state, signOutReason: 'expired' })).toEqual(state) +}) + +it('receives live expiry separately from account snapshots', async () => { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + await once(server, 'listening') + onTestFinished(async () => { + for (const client of server.clients) client.terminate() + await new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()) }) + }) + const address = server.address() + if (typeof address === 'string' || address === null) throw new Error('missing server address') + const connected = once(server, 'connection') + const expired = vi.fn() + const snapshot = { status: 'signed-out', attempt: null, + links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } + const received = Promise.withResolvers() + const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve('')) + const stop = backend.watch((value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired) + onTestFinished(stop) + await connected + // Both logical streams share the socket; observe their opening frames before sending data. + const streams = new Map() + const opened = Promise.withResolvers() + const socket = [...server.clients][0]! + socket.on('message', (data) => { + const frame = JSON.parse(data.toString()) as { endpoint: string; streamId: string } + streams.set(frame.endpoint, frame.streamId) + if (streams.size === 2) opened.resolve(undefined) + }) + await opened.promise + socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot })) + await received.promise + expect(expired).not.toHaveBeenCalled() + socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' })) + await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() }) + stop() }) diff --git a/apps/desktop/tests/welcome-renderer.client.spec.tsx b/apps/desktop/tests/welcome-renderer.client.spec.tsx index 1a39123d19..ee0f06fb09 100644 --- a/apps/desktop/tests/welcome-renderer.client.spec.tsx +++ b/apps/desktop/tests/welcome-renderer.client.spec.tsx @@ -269,7 +269,7 @@ it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Wel const view = mount(language, takeNotice) await act(async () => {}) const publish = view.api.onAccountState.mock.calls[0]![0] - const expired: AccountView = { status: 'signed-out', signOutReason: 'expired', attempt: null, + const expired: AccountView = { status: 'signed-out', attempt: null, links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } } await act(async () => { publish(expired) }) const notice = screen.getByRole('alert') @@ -290,7 +290,7 @@ it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Wel it('does not infer a notification from a retained expired account snapshot', async () => { const view = mount() await act(async () => { - view.api.onAccountState.mock.calls[0]![0]({ status: 'signed-out', signOutReason: 'expired', attempt: null, + view.api.onAccountState.mock.calls[0]![0]({ status: 'signed-out', attempt: null, links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } }) }) expect(screen.queryByRole('alert')).toBeNull() diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 0d19f78585..b2d18224d4 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({ beforeRead: vi.fn(async () => {}), beforeWelcome: vi.fn(async () => {}), copy: vi.fn(), + expiryListener: undefined as (() => void) | undefined, accountListener: undefined as ((value: AccountView) => void) | undefined, accountState: vi.fn<() => Promise>().mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' }, @@ -120,7 +121,11 @@ vi.mock('../src/welcome-backend.ts', () => ({ }, save: async () => ({ ok: true }), account: { - watch: (listener: (value: AccountView) => void) => { state.accountListener = listener; return () => {} }, + watch: (listener: (value: AccountView) => void, _failed: () => void, expired: () => void) => { + state.accountListener = listener + state.expiryListener = expired + return () => {} + }, state: state.accountState, }, }), @@ -239,17 +244,19 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho const welcomeCount = state.beforeWelcome.mock.calls.length state.hasApiKey = true state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) - state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null }) + state.expiryListener!() await vi.advanceTimersByTimeAsync(0) expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount) expect(await state.operations!.takeNotice()).toBeUndefined() state.hasApiKey = false state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) - state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null }) + state.expiryListener!() await vi.waitFor(() => { expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount + 1) }) expect(await state.operations!.takeNotice()).toBe('session-expired') expect(await state.operations!.takeNotice()).toBeUndefined() - state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null }) await vi.advanceTimersByTimeAsync(0) expect(await state.operations!.takeNotice()).toBeUndefined() state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 081eff2e79..7b80ba2548 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 9db87ff7725beda6d0aa22c7fea301bab3fd9a5c -event-producer-consumer.zh.md: 6d45f01d4cc79d4b746d2f757d05a32d0a2a79ce +event-producer-consumer.md: 0b9d154a8c3eb56b6a0334b66eee2314ea0b1c41 +event-producer-consumer.zh.md: 1662f00f62af1898f4ed431b62f9402341d836e3 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9db87ff772..0b9d154a8c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -40,7 +40,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | -| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:57`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | +| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:59`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | +| `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:55`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 6d45f01d4c..1662f00f62 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -42,7 +42,8 @@ | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | -| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:57`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | +| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:59`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | +| `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:55`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 12c977de99..39b3981c62 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.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 docs/subsystems/credentials.md -credentials.md: 46427bd45c50a9ecee173ba05528339692abbb7f -credentials.zh.md: 1c3e6f3d16e08fe06376f77ff79df3ffcc208cde +credentials.md: 341c554f3a59fa87a6a783edc379aeb1f9c1c985 +credentials.zh.md: 33994d405fda20aae2bd5785e2a7a408cc40b370 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 46427bd45c..341c554f3a 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -323,7 +323,7 @@ abstract resolveToken(url: string): Promise /** * Remove an inference-rejected token only while it still matches the stored login. * @param token - token captured by the rejected inference request. - * @returns after matching credentials are removed and expiry state is published. + * @returns after matching credentials are removed and the expiry notification is emitted. */ abstract rejectToken(token: string): Promise @@ -429,6 +429,21 @@ An account model request requires the user to sign in. Source: [`packages/credentials/deepseek-account/src/types.ts`](../../packages/credentials/deepseek-account/src/types.ts) + + +#### `deepseek-account/session-expired` — emit + +Server rejection removed the current account credential; this notification is not replayed. + +```ts cordis-catalog +/** Server rejection removed the current account credential; this notification is not replayed. + * @mode emit + */ +'deepseek-account/session-expired'(): void +``` + +Source: [`packages/credentials/deepseek-account/src/types.ts`](../../packages/credentials/deepseek-account/src/types.ts) + #### `deepseek-account/signed-out` — emit diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 1c3e6f3d16..33994d405f 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -323,7 +323,7 @@ abstract resolveToken(url: string): Promise /** * Remove an inference-rejected token only while it still matches the stored login. * @param token - token captured by the rejected inference request. - * @returns after matching credentials are removed and expiry state is published. + * @returns after matching credentials are removed and the expiry notification is emitted. */ abstract rejectToken(token: string): Promise @@ -429,6 +429,21 @@ An account model request requires the user to sign in. Source: [`packages/credentials/deepseek-account/src/types.ts`](../../packages/credentials/deepseek-account/src/types.ts) + + +#### `deepseek-account/session-expired` — emit + +Server rejection removed the current account credential; this notification is not replayed. + +```ts cordis-catalog +/** Server rejection removed the current account credential; this notification is not replayed. + * @mode emit + */ +'deepseek-account/session-expired'(): void +``` + +Source: [`packages/credentials/deepseek-account/src/types.ts`](../../packages/credentials/deepseek-account/src/types.ts) + #### `deepseek-account/signed-out` — emit diff --git a/packages/api/account-controller/README.i18n.yaml b/packages/api/account-controller/README.i18n.yaml index 8cd04c057c..ba715931d6 100644 --- a/packages/api/account-controller/README.i18n.yaml +++ b/packages/api/account-controller/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/api/account-controller/README.md -README.md: a5d3405353245335a45a26eedc4e4281cf045ecb -README.zh.md: 353d79fac4ab0a01a659d9f1ad9d2944fe01f735 +README.md: 3cc6a7d9bb090868189cb0901ecdeb7045808394 +README.zh.md: 3656b38149188924e7d71abceb52166a47033717 diff --git a/packages/api/account-controller/README.md b/packages/api/account-controller/README.md index a5d3405353..3cc6a7d9bb 100644 --- a/packages/api/account-controller/README.md +++ b/packages/api/account-controller/README.md @@ -22,6 +22,8 @@ Account screens use authenticated Remote commands and a snapshot stream. The con The account namespace exposes getState, getProfile / getBalance, startSignIn, cancelSignIn, signOut, and watch. watch emits an initial complete state and subsequent complete states; disconnecting stops observation, not the login attempt. Cancellation names the attempt ID so a stale screen cannot cancel a newer login. +`watchExpiry` delivers live credential-expiry notifications without an initial item or replay. Desktop uses this stream to hand off a one-shot toast when switching to Welcome. + `hasRunningAccountTasks` checks running Agents through the account-owned predicate over their latest logged request context, including tools and retries. Idle Agents and API-key contexts are excluded. The account provider independently cancels matching tasks when credentials are removed. diff --git a/packages/api/account-controller/README.zh.md b/packages/api/account-controller/README.zh.md index 353d79fac4..3656b38149 100644 --- a/packages/api/account-controller/README.zh.md +++ b/packages/api/account-controller/README.zh.md @@ -22,6 +22,8 @@ kind: "package-reference" account 命名空间提供 getState、getProfile / getBalance、startSignIn、cancelSignIn、signOut 和 watch。watch 先发送完整初始状态,随后发送完整状态变化;断开连接只停止观察,不取消登录。取消操作必须指定尝试 ID,防止旧页面取消新登录。 +`watchExpiry` 仅发送实时凭据失效通知,不发送初始值,也不重放历史通知。桌面端通过该流,在切换到 Welcome 时交接一次性 toast。 + `hasRunningAccountTasks` 通过账号模块的判断函数,检查运行中 Agent 最近记录的请求上下文,包括工具和重试阶段。空闲 Agent 及 API key 上下文不计入。移除凭据时,账号提供方独立取消匹配任务。 diff --git a/packages/api/account-controller/src/index.ts b/packages/api/account-controller/src/index.ts index d713673499..f42e2f752a 100644 --- a/packages/api/account-controller/src/index.ts +++ b/packages/api/account-controller/src/index.ts @@ -59,6 +59,28 @@ export class AccountController extends TypertRemoteService { */ @Remote signOut(): Promise { return this.ctx.deepseekAccount.signOut() } + /** + * Subscribe to credential expiry without replaying prior notifications. + * @param signal - stream lifetime. + * @returns notifications emitted while subscribed. + */ + @Remote({ mode: 'stream' }) + async *watchExpiry(signal: AbortSignal): AsyncIterable<'session-expired'> { + let pending = 0 + let wake: (() => void) | undefined + const stop = this.ctx.on('deepseek-account/session-expired', () => { pending++; wake?.() }) + const abort = (): void => { wake?.() } + signal.addEventListener('abort', abort, { once: true }) + try { + while (!signal.aborted) { + if (pending > 0) { pending--; yield 'session-expired'; continue } + await new Promise((resolve) => { wake = resolve }) + } + } finally { + stop() + signal.removeEventListener('abort', abort) + } + } /** * Stream the safe account projection. * @param signal - stream lifetime. diff --git a/packages/api/account-controller/tests/controller.spec.ts b/packages/api/account-controller/tests/controller.spec.ts index 32d943ca46..d814e93ff7 100644 --- a/packages/api/account-controller/tests/controller.spec.ts +++ b/packages/api/account-controller/tests/controller.spec.ts @@ -22,7 +22,7 @@ function fixture() { watch: vi.fn(), } ctx.provide('deepseekAccount', provider as never) - return { provider, controller: new AccountController(ctx) } + return { ctx, provider, controller: new AccountController(ctx) } } it('delegates account operations without coupling profile and balance queries', async () => { @@ -54,3 +54,20 @@ it('passes the subscriber lifetime to the provider and returns its state stream' for await (const value of stream) received.push(value) expect(received).toEqual([state]) }) + +it('delivers expiry only to current subscribers and stops on disconnect', async () => { + const { ctx, controller } = fixture() + ctx.emit('deepseek-account/session-expired') + const lifetime = new AbortController() + const iterator = controller.watchExpiry(lifetime.signal)[Symbol.asyncIterator]() + const first = iterator.next() + ctx.emit('deepseek-account/session-expired') + expect(await first).toEqual({ done: false, value: 'session-expired' }) + const next = iterator.next() + lifetime.abort() + expect(await next).toEqual({ done: true, value: undefined }) + const reconnect = new AbortController() + const reopened = controller.watchExpiry(reconnect.signal)[Symbol.asyncIterator]().next() + reconnect.abort() + expect(await reopened).toEqual({ done: true, value: undefined }) +}) diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index f52859acb9..daee61cfe8 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -25,6 +25,7 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'api-session/removed', mode: 'emit' }, { event: 'api-session/status', mode: 'emit' }, { event: 'commands/change', mode: 'emit' }, + { event: 'deepseek-account/session-expired', mode: 'emit' }, { event: 'deepseek-account/model-sign-in-required', mode: 'emit' }, { event: 'credentials/record-updated', mode: 'emit' }, { event: 'credentials/reference-updated', mode: 'emit' }, diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 6ea849db43..6600cbedad 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/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/client/ui-settings-account/README.md -README.md: 376862c89e99d821ff750804cf724cf4aac85e6b -README.zh.md: e7f75dd5d05e7acea2450452ce6ec5185373d12a +README.md: d15feec9850b7c3d1faf6a97bdc811278dc4d1a1 +README.zh.md: 113c67bc007240ea3afe4df2e94e73879872d44d diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 376862c89e..d15feec985 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -7,7 +7,7 @@ kind: "package-reference" English | [中文](README.zh.md) -Server-expired account credentials show a localized sign-in reminder toast and clear the displayed account details. +Server-expired account credentials clear the displayed account details and emit a localized sign-in reminder toast once; account snapshot replay does not repeat it. ## Summary diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index e7f75dd5d0..113c67bc00 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -7,7 +7,7 @@ kind: "package-reference" [English](README.md) | 中文 -账号凭据被服务端判定失效后,显示本地化的重新登录提示 toast,并清空显示的账号详情。 +账号凭据被服务端判定失效后,清空显示的账号详情,并弹出一次本地化的重新登录提示 toast;账号快照重放不会重复提示。 ## 概述 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index a8fab33a82..83a94ba581 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -19,17 +19,17 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<' * @returns account menu launcher. */ export function AccountMenu({ - subscribeModelSignInRequired, wide, openSettings, openOnboarding, useAccount, useTheme, signOut, hasRunningAccountTasks, + subscribeSessionExpired, subscribeModelSignInRequired, wide, openSettings, openOnboarding, useAccount, useTheme, + signOut, hasRunningAccountTasks, contactUs, showLogin, start, cancel, t, }: AccountMenuProps) { const account = useAccount(state => state) const colorScheme = useTheme(snapshot => snapshot.active.colorScheme) const signedIn = account.view?.status === 'credential-stored' - const expired = account.view?.status === 'signed-out' && account.view.signOutReason === 'expired' const [signInNotice, setSignInNotice] = useState(0) useEffect(() => subscribeModelSignInRequired?.(() => { setSignInNotice(value => value + 1) }), [subscribeModelSignInRequired]) const [expiryNotice, setExpiryNotice] = useState(false) - useEffect(() => { setExpiryNotice(expired) }, [expired]) + useEffect(() => subscribeSessionExpired?.(() => { setExpiryNotice(true) }), [subscribeSessionExpired]) const profile = account.details?.profile const label = profile === undefined ? null : profile.status === 'ready' ? profile.value.name ?? profile.value.contact ?? t('signedIn') : t('signedIn') diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index 597adef18c..b8482330fe 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -29,6 +29,11 @@ export interface AccountSnapshot { /** Host operations injected into the Cordis-free account component. */ export interface AccountSectionInjected { + /** Subscribe to live credential-expiry notifications. + * @param listener - callback after the current credential is removed. + * @returns listener cleanup. + */ + subscribeSessionExpired?: (listener: () => void) => () => void /** Subscribe to live model sign-in guidance; the returned function removes the listener. * @param listener - callback for one rejected account-model request. * @returns listener cleanup. diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 006a82bca0..5817b0d461 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -89,6 +89,7 @@ export function apply(ctx: Context): void { })().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) }) const nativePlatform = (globalThis as typeof globalThis & { dshPlatform?: PlatformBridge }).dshPlatform const operations: AccountSectionInjected = { + subscribeSessionExpired: listener => ctx.remote.$on('deepseek-account/session-expired', listener), subscribeModelSignInRequired: listener => ctx.remote.$on('deepseek-account/model-sign-in-required', listener), ...nativePlatform === undefined ? {} : { platform: nativePlatform }, refresh, diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 8905710af7..3a8eb98383 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -373,17 +373,27 @@ it('opens more account information externally without invoking the embedded Plat it.each([en, zh])('shows a localized toast when the account credential expires', async (copy) => { vi.useFakeTimers() onTestFinished(() => { vi.useRealTimers() }) - const operations = mount({ status: 'signed-out', attempt: null, signOutReason: 'expired' }, copy) + const operations = mount({ status: 'signed-out', attempt: null }, copy) cleanup() const { AccountMenu } = await import('../src/client/AccountMenu.tsx') - render( void) | undefined + const unsubscribe = vi.fn() + const element = { expire = listener; return unsubscribe }} useAccount={selector => selector(operations.hooks.account.getSnapshot())} useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide openOnboarding={() => {}} openSettings={() => {}} - t={key => key in copy ? copy[key as AccountKey] : key} />) + t={key => key in copy ? copy[key as AccountKey] : key} /> + const view = render(element) + expect(screen.queryByRole('alert')).toBeNull() + act(() => { expire!() }) expect(screen.getByRole('alert').textContent).toContain(copy.sessionExpired) await expect(`${screen.getByRole('alert').textContent}\n`).toMatchFileSnapshot(`./expected/expired-${copy === en ? 'en' : 'zh'}.txt`) await act(async () => { await vi.advanceTimersByTimeAsync(4000) }) expect(screen.queryByRole('alert')).toBeNull() + view.unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + render(element) + expect(screen.queryByRole('alert')).toBeNull() }) it.each(['initializing', 'waiting-browser', 'exchanging'] as const)('shows %s and lets the user cancel the active attempt', async (phase) => { diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 69ffd59af5..812ac5e789 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/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/credentials/deepseek-account-platform/README.md -README.md: ecb4f9616bc9da0d25d5d8f70690efa983b35279 -README.zh.md: e384b982def75b07bb7290ca1bf70b1b4d144c70 +README.md: 8db7824fee19d70fd895ee9012e2a933f28a53c9 +README.zh.md: b4525f135f772623ed0f9e6241b11390dad82b85 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index ecb4f9616b..8db7824fee 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,7 +13,7 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. Every profile then sends `x-client-platform: web` on Host authorization, profile, balance, and logout requests; the Desktop profile supplies `darwin` or `win32`, replacing it with `x-client-platform: desktop-mac` or `desktop-win`. The provider owns that header, so deployment configuration cannot override it. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. -Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. Host inference consumers can report a rejected request token through `rejectToken`; removal requires that it still matches the stored login. +Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and emit the live `deepseek-account/session-expired` notification. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. Host inference consumers can report a rejected request token through `rejectToken`; removal requires that it still matches the stored login. ## Summary diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index e384b982de..b4525f135f 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,7 +13,7 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。此时所有 profile 的 Host 授权、资料、余额和退登请求都携带 `x-client-platform: web`;Desktop profile 提供 `darwin` 或 `win32`,改为携带 `x-client-platform: desktop-mac` 或 `desktop-win`。该请求头由 provider 拥有,部署配置无法覆盖。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头。 -资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。Host 推理调用方可以通过 `rejectToken` 报告被拒绝的请求 token;仅当它仍匹配当前登录凭据时才执行清除。 +资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发送实时 `deepseek-account/session-expired` 通知。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。Host 推理调用方可以通过 `rejectToken` 报告被拒绝的请求 token;仅当它仍匹配当前登录凭据时才执行清除。 ## 概述 diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 0a731ff8d6..811e9ef3d0 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -98,7 +98,6 @@ export class PlatformAccount extends DeepSeekAccount { private detailsLifetime = new AbortController() private closed = false private removing: Promise | undefined - private signOutReason: 'expired' | undefined /** @param ctx - Host with authorization and credentials services. @param config - deployment options. */ constructor(ctx: Context, config: Config = {}) { @@ -173,7 +172,6 @@ export class PlatformAccount extends DeepSeekAccount { } const attempt = this.attempt?.view ?? null return { - ...(record === undefined && this.signOutReason !== undefined ? { signOutReason: this.signOutReason } : {}), status: record === undefined ? 'signed-out' : 'credential-stored', // Credential removal can notify watchers before sign-out finishes clearing the attempt. attempt: record === undefined && attempt?.phase === 'succeeded' ? null : attempt, @@ -227,9 +225,8 @@ export class PlatformAccount extends DeepSeekAccount { if (this.closed || this.detailsLifetime !== lifetime || record?.kind !== 'grant') return this.getState() const current = grant.parse(record.payload) if (current.token !== token || current.issuer !== this.origin) return this.getState() - this.signOutReason = 'expired' - try { await this.ctx.credentials.deleteRecord(KEY) } - catch (error) { this.signOutReason = undefined; throw error } + await this.ctx.credentials.deleteRecord(KEY) + this.ctx.emit('deepseek-account/session-expired') this.attempt = undefined this.ctx.emit('deepseek-account/signed-out') this.changed() @@ -281,7 +278,6 @@ export class PlatformAccount extends DeepSeekAccount { override async startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise { if (this.removing !== undefined) await this.removing - this.signOutReason = undefined const origin = loginOrigin(callbackOrigin) if (this.closed) throw new PlatformAuthError('protocol') if (this.attempt !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(this.attempt.view.phase)) { @@ -340,7 +336,6 @@ export class PlatformAccount extends DeepSeekAccount { } override signOut(): Promise { - this.signOutReason = undefined this.removing ??= (async () => { if (this.closed) throw new PlatformAuthError('protocol') if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index c4c6278d1f..0b8d0d27ce 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -886,11 +886,14 @@ it.each(['profile', 'balance'] as const)('clears the rejected account grant afte await storeAccount(f) let signedOut = 0 f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + const expired = vi.fn() + f.ctx.on('deepseek-account/session-expired', expired) f.detailStatus(401) expect(await (field === 'profile' ? f.account.getProfile() : f.account.getBalance())).toBeNull() - expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired', attempt: null }) + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', attempt: null }) expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toBeUndefined() expect(signedOut).toBe(1) + expect(expired).toHaveBeenCalledOnce() }) it.each(['profile', 'balance'] as const)('clears the rejected account grant after a %s HTTP 200 with code 40003', async (field) => { @@ -898,11 +901,14 @@ it.each(['profile', 'balance'] as const)('clears the rejected account grant afte await storeAccount(f) let signedOut = 0 f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + const expired = vi.fn() + f.ctx.on('deepseek-account/session-expired', expired) f.detailCode(40003) expect(await (field === 'profile' ? f.account.getProfile() : f.account.getBalance())).toBeNull() - expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired', attempt: null }) + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', attempt: null }) expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toBeUndefined() expect(signedOut).toBe(1) + expect(expired).toHaveBeenCalledOnce() }) it('coalesces simultaneous unauthorized profile and balance responses', async () => { @@ -910,10 +916,13 @@ it('coalesces simultaneous unauthorized profile and balance responses', async () await storeAccount(f) let signedOut = 0 f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + const expired = vi.fn() + f.ctx.on('deepseek-account/session-expired', expired) f.detailStatus(401) await Promise.all([f.account.getProfile(), f.account.getBalance()]) expect(signedOut).toBe(1) - expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired' }) + expect(expired).toHaveBeenCalledOnce() + expect(await f.account.getState()).toMatchObject({ status: 'signed-out' }) }) it.each([403, 500])('retains the account grant after HTTP %s', async (status) => { @@ -1289,7 +1298,7 @@ it('cancels a pending sign-in when the current stored grant expires', async () = await f.wait('waiting-browser') f.detailStatus(401) expect(await f.account.getBalance()).toBeNull() - expect(await f.account.getState()).toMatchObject({ status: 'signed-out', attempt: null, signOutReason: 'expired' }) + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', attempt: null }) }) it('retains the grant when local expiry removal fails and permits a later retry', async () => { @@ -1300,9 +1309,9 @@ it('retains the grant when local expiry removal fails and permits a later retry' try { await expect(f.account.getBalance()).rejects.toThrow('storage failed') expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) - expect((await f.account.getState()).signOutReason).toBeUndefined() + expect(await f.account.getState()).not.toHaveProperty('signOutReason') expect(await f.account.getBalance()).toBeNull() - expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired' }) + expect(await f.account.getState()).toMatchObject({ status: 'signed-out' }) } finally { remove.mockRestore() } }) @@ -1359,16 +1368,18 @@ it('expires an inference-rejected token without another Platform request', async await storeAccount(f) const published: string[] = [] f.ctx.on('deepseek-account/signed-out', () => { published.push('signed-out') }) + f.ctx.on('deepseek-account/session-expired', () => { published.push('session-expired') }) + await f.account.rejectToken('test-platform-grant') await f.account.rejectToken('test-platform-grant') const state = await f.account.getState() - expect({ status: state.status, reason: state.signOutReason, published, detailRequests: f.detailRequests }) + expect({ status: state.status, published, detailRequests: f.detailRequests }) .toMatchInlineSnapshot(` { "detailRequests": [], "published": [ + "session-expired", "signed-out", ], - "reason": "expired", "status": "signed-out", } `) @@ -1387,7 +1398,7 @@ it('ignores an inference rejection while already signed out', async () => { const f = await fixture() await f.account.rejectToken('previous-platform-grant') expect(await f.account.getState()).toMatchObject({ status: 'signed-out' }) - expect((await f.account.getState()).signOutReason).toBeUndefined() + expect(await f.account.getState()).not.toHaveProperty('signOutReason') }) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 586afa4831..f59e08d93b 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/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/credentials/deepseek-account/README.md -README.md: 804b929fe27ecdc82569f002dbbc595ae9b0219b -README.zh.md: 8403eb004e33b9cc3e08dca5f1e96c18d1abaee4 +README.md: f82cdcfec18d913cec1dba5ca0a2a1a0f0a1404c +README.zh.md: 94efac994da792e3bdb4a819a860c4bb9a2d0419 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 804b929fe2..f82cdcfec1 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -13,7 +13,7 @@ getPlatformSession returns a Host-only origin/token snapshot for native Platform `rejectToken` accepts a Host inference request’s rejected token and removes only the matching current login; a late rejection cannot clear a replacement credential. -`AccountView.signOutReason` identifies a server-expired credential separately from explicit sign-out; it is absent while signed in. +`deepseek-account/session-expired` notifies current subscribers once after a rejected credential is removed. Account snapshots carry no expiry notice, so reconnecting does not repeat the toast. ## Summary diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index 8403eb004e..94efac994d 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -13,7 +13,7 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token `rejectToken` 接收 Host 推理请求被拒绝的 token,仅删除与它匹配的当前登录凭据;延迟返回的拒绝不能清除替换后的凭据。 -`AccountView.signOutReason` 区分服务端判定凭据失效和主动退登;登录状态下不携带该字段。 +`deepseek-account/session-expired` 在移除被拒绝的凭据后,向当前订阅方通知一次。账号快照不携带失效提示,因此重新连接不会重复弹出 toast。 ## 概述 diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index 8197127979..9a40ecf712 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -79,7 +79,7 @@ export abstract class DeepSeekAccount extends Service { /** * Remove an inference-rejected token only while it still matches the stored login. * @param token - token captured by the rejected inference request. - * @returns after matching credentials are removed and expiry state is published. + * @returns after matching credentials are removed and the expiry notification is emitted. */ abstract rejectToken(token: string): Promise /** diff --git a/packages/credentials/deepseek-account/src/types.ts b/packages/credentials/deepseek-account/src/types.ts index c88550d101..a7cbe846ab 100644 --- a/packages/credentials/deepseek-account/src/types.ts +++ b/packages/credentials/deepseek-account/src/types.ts @@ -22,8 +22,6 @@ export interface AccountLinks { /** Stored-account presence is not a claim that the server has validated its token. */ export interface AccountView { readonly status: 'signed-out' | 'credential-stored' - /** Server rejection cleared the local credential. */ - readonly signOutReason?: 'expired' readonly links: AccountLinks readonly attempt: SignInAttemptView | null } @@ -51,6 +49,10 @@ export interface AccountDetails { declare module '@deepseek-ai/cordis' { interface Events { + /** Server rejection removed the current account credential; this notification is not replayed. + * @mode emit + */ + 'deepseek-account/session-expired'(): void /** An account model request requires the user to sign in. * @mode emit */ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4362b7b9f2..6b2531b561 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -893,7 +893,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract rejectToken(token: string): Promise', description: 'Remove an inference-rejected token only while it still matches the stored login.', parameters: [{ name: 'token', description: 'token captured by the rejected inference request.' }], - returns: 'after matching credentials are removed and expiry state is published.', + returns: 'after matching credentials are removed and the expiry notification is emitted.', }, { signature: 'abstract getPlatformSession(): Promise', @@ -3879,6 +3879,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'An account model request requires the user to sign in.', parameters: [], }, + { + name: 'deepseek-account/session-expired', + mode: 'emit', + signature: '\'deepseek-account/session-expired\'(): void', + summary: 'Server rejection removed the current account credential; this notification is not replayed.', + description: 'Server rejection removed the current account credential; this notification is not replayed.', + parameters: [], + }, { name: 'deepseek-account/signed-out', mode: 'emit', @@ -4261,7 +4269,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AccountView', - declaration: 'export interface AccountView {\n readonly status: \'signed-out\' | \'credential-stored\';\n readonly signOutReason?: \'expired\';\n readonly links: AccountLinks;\n readonly attempt: SignInAttemptView | null;\n}', + declaration: 'export interface AccountView {\n readonly status: \'signed-out\' | \'credential-stored\';\n readonly links: AccountLinks;\n readonly attempt: SignInAttemptView | null;\n}', }, { name: 'AccountWallet', From e310916e59375d7e5d70ad3461bf441b51724b7a Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:26:32 +0800 Subject: [PATCH 24/30] refactor(llm): let providers resolve request authentication --- .../llm/llm-deepseek-account/README.i18n.yaml | 4 +- packages/llm/llm-deepseek-account/README.md | 2 + .../llm/llm-deepseek-account/README.zh.md | 2 + .../llm/llm-deepseek-account/src/index.ts | 29 ++++++------ .../llm/llm-deepseek-api-key/README.i18n.yaml | 4 +- packages/llm/llm-deepseek-api-key/README.md | 2 + .../llm/llm-deepseek-api-key/README.zh.md | 2 + .../llm/llm-deepseek-api-key/src/index.ts | 3 +- 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 | 11 ++--- packages/llm/llm-deepseek/src/file-store.ts | 10 ++-- packages/llm/llm-deepseek/src/files-api.ts | 15 +++--- packages/llm/llm-deepseek/src/host.ts | 2 +- packages/llm/llm-deepseek/src/index.ts | 2 +- packages/llm/llm-deepseek/src/types.ts | 21 ++++----- packages/llm/llm-deepseek/src/upload-index.ts | 8 ++-- .../llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 24 ++++++++-- .../llm/llm-deepseek/tests/file-store.spec.ts | 19 ++++++-- .../llm/llm-deepseek/tests/files-api.spec.ts | 46 +++++++++---------- packages/llm/llm-deepseek/tests/files.spec.ts | 4 +- packages/llm/llm-deepseek/tests/helpers.ts | 2 +- .../llm-deepseek/tests/prepared-call.spec.ts | 2 +- .../llm/llm-deepseek/tests/runtime.e2e.ts | 2 +- .../llm/llm-deepseek/tests/runtime.spec.ts | 32 ++++++------- .../continuation-messages-adapter.spec.ts | 2 +- 28 files changed, 149 insertions(+), 111 deletions(-) diff --git a/packages/llm/llm-deepseek-account/README.i18n.yaml b/packages/llm/llm-deepseek-account/README.i18n.yaml index 29356a8844..c55756b190 100644 --- a/packages/llm/llm-deepseek-account/README.i18n.yaml +++ b/packages/llm/llm-deepseek-account/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-account/README.md -README.md: 1186a81f51ff9b9595100fb04d4eb5fb20af6dbc -README.zh.md: fec40b144f55d369e0ef0e6b6d69a6e45fe28f9b +README.md: 13b6df830e7ef16a0b75ff46678c507822f543b7 +README.zh.md: f2ca9fe281a9a10f1e75b9a941ad67da21d86b63 diff --git a/packages/llm/llm-deepseek-account/README.md b/packages/llm/llm-deepseek-account/README.md index 1186a81f51..13b6df830e 100644 --- a/packages/llm/llm-deepseek-account/README.md +++ b/packages/llm/llm-deepseek-account/README.md @@ -11,6 +11,8 @@ English | [中文](README.zh.md) Register authentication and model discovery for `deepseek-account`. This plugin shares the [Messages transport](../llm-deepseek/README.md) and owns credential resolution and catalog availability. +Authentication resolution returns `x-dsh-auth-token` and a failure callback capturing the same token. HTTP 401 classification and rejection stay in this provider; a rejected older request cannot clear a replacement login. + ## Table of Contents - [Use this package](#use-this-package) diff --git a/packages/llm/llm-deepseek-account/README.zh.md b/packages/llm/llm-deepseek-account/README.zh.md index fec40b144f..f2ca9fe281 100644 --- a/packages/llm/llm-deepseek-account/README.zh.md +++ b/packages/llm/llm-deepseek-account/README.zh.md @@ -11,6 +11,8 @@ kind: "package-reference" 为 `deepseek-account` 注册独立的鉴权和模型发现插件。通过 [llm-deepseek](../llm-deepseek/README.zh.md) 共用 Messages 请求实现;本包拥有凭据解析和目录可用性判断。 +鉴权解析返回 `x-dsh-auth-token` 和捕获同一 token 的失败回调。HTTP 401 的分类和凭据失效处理由此提供方负责;被拒绝的旧请求不能清除替换后的登录。 + ## 目录 - [使用此包](#use-this-package) diff --git a/packages/llm/llm-deepseek-account/src/index.ts b/packages/llm/llm-deepseek-account/src/index.ts index 17cf773315..847a792cd2 100644 --- a/packages/llm/llm-deepseek-account/src/index.ts +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -5,7 +5,7 @@ import { LlmError } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-deepseek-account' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { plainOptions, resolveAdapterOptions, registerDeepSeekProvider, catalogModelInfo } from '@deepseek-ai/dsh-llm-deepseek' -import type { ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek' +import type { DeepSeekRequestAuth, ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek' import { Config } from './config.ts' export { Config } from './config.ts' @@ -17,26 +17,29 @@ const PROVIDER = 'deepseek-account' export function apply(ctx: Context, config: Config): void { const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx)) options() - const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { - const token = await ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) + const resolveAuth = async (connection: ResolvedDeepSeekOptions): Promise => { + const account = ctx.get('deepseekAccount') + const token = await account?.resolveToken(connection.baseURL) if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED') - return token + return { + headers: { 'x-dsh-auth-token': token }, + onRequestError: async (error) => { + if (!(error instanceof LlmError) || error.failure.status !== 401) return error + const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error }) + try { await account?.rejectToken(token) } + catch (_credentialRemovalFailed) { /* Storage failure cannot replace the inference failure. */ } + return rejected + }, + } } ctx.llm.registerConfigurableProviders([ { provider: PROVIDER, displayName: 'DeepSeek Account', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] }, ]) registerDeepSeekProvider(ctx, PROVIDER, { - options, resolveApiKey, providerName: 'DeepSeek Account', accountCredential: true, - onRequestError: async (error, token) => { - if (!(error instanceof LlmError) || error.failure.status !== 401) return error - const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error }) - try { await ctx.get('deepseekAccount')?.rejectToken(token) } - catch (_credentialRemovalFailed) { /* Storage failure cannot replace the inference failure. */ } - return rejected - }, + options, resolveAuth, providerName: 'DeepSeek Account', discoverModels: async (provider) => { const connection = options() - try { await resolveApiKey(connection) } + try { await resolveAuth(connection) } catch (error) { if (error instanceof LlmError && error.code === 'ACCOUNT_SIGN_IN_REQUIRED') return [] throw error diff --git a/packages/llm/llm-deepseek-api-key/README.i18n.yaml b/packages/llm/llm-deepseek-api-key/README.i18n.yaml index 8a9a98f3b0..4a24a98eb7 100644 --- a/packages/llm/llm-deepseek-api-key/README.i18n.yaml +++ b/packages/llm/llm-deepseek-api-key/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-api-key/README.md -README.md: 728747d18b0128c94a1e708eae193def55a5df6e -README.zh.md: 5fb38c82ea7e563e1dea41170384f6f52ea756ee +README.md: 4b22ef2ae97c46ab888714ad436f67f9df1ade3f +README.zh.md: df6baea12344db8873fc870027f92e0d5ae882a7 diff --git a/packages/llm/llm-deepseek-api-key/README.md b/packages/llm/llm-deepseek-api-key/README.md index 728747d18b..4b22ef2ae9 100644 --- a/packages/llm/llm-deepseek-api-key/README.md +++ b/packages/llm/llm-deepseek-api-key/README.md @@ -11,6 +11,8 @@ English | [中文](README.zh.md) Register authentication and model discovery for `deepseek-official`. This plugin shares the [Messages transport](../llm-deepseek/README.md) and owns credential resolution and catalog availability. +Authentication resolution returns the validated API key in `x-api-key` for both Messages and Files requests. + ## Table of Contents - [Use this package](#use-this-package) diff --git a/packages/llm/llm-deepseek-api-key/README.zh.md b/packages/llm/llm-deepseek-api-key/README.zh.md index 5fb38c82ea..df6baea123 100644 --- a/packages/llm/llm-deepseek-api-key/README.zh.md +++ b/packages/llm/llm-deepseek-api-key/README.zh.md @@ -11,6 +11,8 @@ kind: "package-reference" 为 `deepseek-official` 注册独立的鉴权和模型发现插件。通过 [llm-deepseek](../llm-deepseek/README.zh.md) 共用 Messages 请求实现;本包拥有凭据解析和目录可用性判断。 +鉴权解析将验证后的 API key 放入 `x-api-key`,供 Messages 和 Files 请求共同使用。 + ## 目录 - [使用此包](#use-this-package) diff --git a/packages/llm/llm-deepseek-api-key/src/index.ts b/packages/llm/llm-deepseek-api-key/src/index.ts index 128ede2b6f..94280483e0 100644 --- a/packages/llm/llm-deepseek-api-key/src/index.ts +++ b/packages/llm/llm-deepseek-api-key/src/index.ts @@ -37,7 +37,8 @@ export function apply(ctx: Context, config: Config): void { { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] }, ]) registerDeepSeekProvider(ctx, PROVIDER, { - options, resolveApiKey, providerName: 'DeepSeek', + options, providerName: 'DeepSeek', + resolveAuth: async connection => ({ headers: { 'x-api-key': await resolveApiKey(connection) } }), discoverModels: async (provider) => { const connection = options() return connection.models.map(model => catalogModelInfo(provider, model)) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 6aaafef79d..8fc2c2ab8d 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: 5e569f3bf63e0280112861843d00d48516d5e23c -README.zh.md: 9df5b417e33a229ced1fdc059e957204841de01b +README.md: f877eb53db82e76b23509a20f39de66d72bc9386 +README.zh.md: cd2da99286705913c07bed9e9ded8a9c198e3d6b diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5e569f3bf6..f877eb53db 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -11,6 +11,8 @@ English | [中文](README.zh.md) Provide the shared DeepSeek Messages transport, request configuration, and model capabilities. Compose [API-key](../llm-deepseek-api-key/README.md) or [account](../llm-deepseek-account/README.md) plugins for authentication, model discovery, and provider registration. Valid settings changes affect subsequent calls while in-flight calls retain their configuration. This package can run beside the [pi-ai adapter](../llm-pi-ai/README.md). +`resolveAuth(connection)` returns the provider-owned authentication headers and an optional failure callback bound to that request’s credential. Messages and Files send those headers without selecting a credential mode. Upload reuse is isolated by endpoint and a hash of the authentication headers; raw credentials are not persisted. + ## Table of Contents - [Use this package](#use-this-package) diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 9df5b417e3..cd2da99286 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -11,6 +11,8 @@ kind: "package-reference" 提供共享的 DeepSeek Messages 传输、请求配置和模型能力。组合 [API key](../llm-deepseek-api-key/README.zh.md) 或[账号](../llm-deepseek-account/README.zh.md)插件以提供鉴权、模型发现与 provider 注册。有效的设置更改在后续请求生效,进行中的请求保留原配置。本包可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。 +`resolveAuth(connection)` 返回提供方持有的鉴权请求头,以及绑定本次请求凭据的可选失败回调。Messages 和 Files 直接使用这些请求头,不判断凭据类型。上传复用按端点和鉴权请求头的哈希隔离,不持久化原始凭据。 + ## 目录 - [使用本包](#use-this-package) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 2092e1227a..67f9890047 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -79,11 +79,10 @@ export class DeepSeekAdapter extends LlmAdapt const { messages, versions } = await prepareImages( options.messages, connection, options.model, this.dependencies.resolveAttachments?.(), this.imageAccess, signal, ) - const key = await this.dependencies.resolveApiKey(connection) - const accountToken = this.dependencies.accountCredential === true ? key : undefined + const auth = await this.dependencies.resolveAuth(connection) try { const files = new RequestFiles(this.files, { - baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, + baseURL: connection.baseURL, headers: auth.headers, }, connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) let inline = false @@ -115,7 +114,7 @@ export class DeepSeekAdapter extends LlmAdapt headers: { ...attributionHeaders(), 'content-type': 'application/json', 'accept': 'text/event-stream', - ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, + ...auth.headers, 'anthropic-version': '2023-06-01', ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), @@ -141,9 +140,9 @@ export class DeepSeekAdapter extends LlmAdapt return } } catch (error) { - if (this.dependencies.onRequestError !== undefined) { + if (auth.onRequestError !== undefined) { let mapped: unknown - try { mapped = await this.dependencies.onRequestError(error, key) } + try { mapped = await auth.onRequestError(error) } catch (_credentialUpdateFailed) { throw error } throw mapped } diff --git a/packages/llm/llm-deepseek/src/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts index 7d5bf4e8d1..cebff0809b 100644 --- a/packages/llm/llm-deepseek/src/file-store.ts +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -22,9 +22,8 @@ export interface DeepSeekFilePolicy { /** Connection facts needed by file operations. */ export interface DeepSeekFileConnection { baseURL: string - apiKey: string - /** Use the DSH account header; omitted for ordinary API keys. */ - accountCredential?: boolean + /** Provider-resolved authentication headers for this endpoint. */ + headers: Readonly> } /** Result of one file-id resolution. */ @@ -50,7 +49,7 @@ interface SharedUpload { function fileScope(connection: DeepSeekFileConnection) { return deepSeekFileScope( messagesApiRoot(connection.baseURL), - connection.apiKey, + JSON.stringify(Object.entries(connection.headers).sort(([left], [right]) => left.localeCompare(right))), ) } @@ -137,8 +136,7 @@ export class DeepSeekFileStore { private client(connection: DeepSeekFileConnection): DeepSeekFilesClient { return new DeepSeekFilesClient({ baseURL: connection.baseURL, - apiKey: connection.apiKey, - ...connection.accountCredential === undefined ? {} : { accountCredential: connection.accountCredential }, + headers: connection.headers, ...this.fetchImpl === undefined ? {} : { fetch: this.fetchImpl }, }) } diff --git a/packages/llm/llm-deepseek/src/files-api.ts b/packages/llm/llm-deepseek/src/files-api.ts index 181580ce6f..9aa1f005d7 100644 --- a/packages/llm/llm-deepseek/src/files-api.ts +++ b/packages/llm/llm-deepseek/src/files-api.ts @@ -70,9 +70,8 @@ export function isFilesQuotaError(error: unknown): error is DeepSeekFilesError { interface FilesApiOptions { baseURL: string - apiKey: string - /** Use the DSH account header; omitted for ordinary API keys. */ - accountCredential?: boolean + /** Provider-resolved authentication headers for this endpoint. */ + headers: Readonly> fetch?: typeof fetch } @@ -131,16 +130,14 @@ function providerErrorDetail(value: unknown): { message?: string; detail: string /** Direct Files client retaining the configured URL root and refusing redirects before credentials can leave its origin. */ export class DeepSeekFilesClient { private readonly baseURL: string - private readonly accountCredential: boolean - private readonly apiKey: string + private readonly authHeaders: Readonly> private readonly fetchImpl: typeof fetch /** - * @param options - endpoint, API-key snapshot, and optional test transport. + * @param options - endpoint, authentication headers, and optional test transport. */ constructor(options: FilesApiOptions) { - this.apiKey = options.apiKey - this.accountCredential = options.accountCredential === true + this.authHeaders = options.headers this.fetchImpl = options.fetch ?? globalThis.fetch this.baseURL = messagesApiRoot(options.baseURL) } @@ -149,7 +146,7 @@ export class DeepSeekFilesClient { let response: Response try { const headers = new Headers(attributionHeaders()) - headers.set(this.accountCredential ? 'x-dsh-auth-token' : 'x-api-key', this.apiKey) + for (const [name, value] of Object.entries(this.authHeaders)) headers.set(name, value) headers.set('anthropic-version', '2023-06-01') headers.set('anthropic-beta', MESSAGES_FILES_BETA) response = await this.fetchImpl(`${this.baseURL}${path}`, { diff --git a/packages/llm/llm-deepseek/src/host.ts b/packages/llm/llm-deepseek/src/host.ts index 2149472ba8..a98fecbec5 100644 --- a/packages/llm/llm-deepseek/src/host.ts +++ b/packages/llm/llm-deepseek/src/host.ts @@ -17,7 +17,7 @@ import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions } from './types. */ export function registerDeepSeekProvider( ctx: Context, provider: string, dependencies: Pick, - 'options' | 'resolveApiKey' | 'accountCredential' | 'onRequestError' | 'providerName' | 'discoverModels'>): void { + 'options' | 'resolveAuth' | 'providerName' | 'discoverModels'>): void { ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) let userId: AnonymousUserId | undefined const adapter = new DeepSeekAdapter({ diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 89a1cce2ca..623dbea335 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -15,7 +15,7 @@ export { DEFAULT_STREAM_IDLE_TIMEOUT_MS, } from './defaults.ts' export { DeepSeekAdapter } from './adapter.ts' -export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts' +export type { DeepSeekRequestAuth, DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts' export { DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET, DEFAULT_MAX_IMAGES_PER_REQUEST, diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 4257db1c45..60c6411696 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -74,6 +74,14 @@ export interface DeepSeekConnectionOptions { retryPolicy: ResolvedRetryPolicy } +/** Authentication captured by the provider for one request and its file operations. */ +export interface DeepSeekRequestAuth { + /** Credential headers sent unchanged to the resolved endpoint. */ + headers: Readonly> + /** Classify a failure using the captured credential; callback failure preserves the original error. */ + onRequestError?: (error: unknown) => Promise +} + /** Constructor options for {@link DeepSeekAdapter}: the operation-local resolution hooks the plugin owns. */ export interface DeepSeekAdapterOptions { /** Report unusable native Messages replay metadata without exposing content or signatures. */ @@ -82,19 +90,10 @@ export interface DeepSeekAdapterOptions Promise - /** Classify a request failure and update credential state; callback failure preserves the original error. */ - onRequestError?: (error: unknown, credential: string) => Promise /** Current validated connection facts; called once per operation. */ options: () => Connection - /** - * Resolve this adapter’s credential for the connection facts of one request. The - * snapshot is passed in — never re-read — so the key can only ever come - * from the same resolution as the endpoint it is sent to. Throws `LlmError` - * `MISSING_CREDENTIAL` for a missing API key; account routes reject unavailable grants. - */ - resolveApiKey: (connection: Connection) => Promise - /** Send the resolved credential as a bare DSH account token; never fall back to an API key. */ - accountCredential?: boolean + /** Resolve authentication from this request's connection snapshot; never re-read the endpoint. */ + resolveAuth: (connection: Connection) => Promise /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ resolveUserId: () => AnonymousUserId /** Resolve the current durable attachment service; absence rejects image input. */ diff --git a/packages/llm/llm-deepseek/src/upload-index.ts b/packages/llm/llm-deepseek/src/upload-index.ts index d442bb55fe..1467e7d93a 100644 --- a/packages/llm/llm-deepseek/src/upload-index.ts +++ b/packages/llm/llm-deepseek/src/upload-index.ts @@ -37,16 +37,16 @@ export interface UploadIndexCommit { } /** - * Derive a non-secret stable index namespace without persisting or logging the API key. + * Derive a non-secret stable index namespace without persisting or logging authentication headers. * @param baseURL - normalized provider endpoint namespace. - * @param apiKey - resolved credential used only as hash input. + * @param credentials - serialized authentication headers used only as hash input. * @returns branded SHA-256 namespace digest. */ -export function deepSeekFileScope(baseURL: string, apiKey: string): DeepSeekFileScopeType { +export function deepSeekFileScope(baseURL: string, credentials: string): DeepSeekFileScopeType { const digest = createHash('sha256') .update(baseURL.replace(/\/+$/u, '')) .update('\0') - .update(apiKey) + .update(credentials) .digest('hex') return DeepSeekFileScope(digest) } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 0a5dd3f8f9..4bf5cd2050 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -76,7 +76,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () const uploads: string[] = [] const bodies: string[] = [] const files = new DeepSeekFilesClient({ - baseURL: Protocol.PUBLIC_BASE_URL, apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl, + baseURL: Protocol.PUBLIC_BASE_URL, headers: { 'x-api-key': process.env.DEEPSEEK_API_KEY as string }, fetch: fetchImpl, }) const ownedFiles = new Set>() cleanups.push(async () => { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 96b323587e..919800f696 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -165,7 +165,7 @@ describe('direct Messages HTTP', () => { const files = new DeepSeekFileStore() const llm = new DeepSeekAdapter({ options: () => Messages.resolveAdapterOptions({ baseURL: source.url }), - resolveApiKey: () => Promise.resolve('test-key'), resolveUserId: () => 'test-user' as AnonymousUserId, + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'test-key' } }), resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions: prepare, }) @@ -182,7 +182,7 @@ describe('direct Messages HTTP', () => { const first = await endpoint(), second = await endpoint() let config = Messages.resolveAdapterOptions({ baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) const files = new DeepSeekFileStore() - const llm = new DeepSeekAdapter({ options: () => config, resolveApiKey: snapshot => Promise.resolve(snapshot.maxTokens === 10 ? 'first' : 'second'), resolveUserId: () => 'user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) + const llm = new DeepSeekAdapter({ options: () => config, resolveAuth: snapshot => Promise.resolve({ headers: { 'x-api-key': snapshot.maxTokens === 10 ? 'first' : 'second' } }), resolveUserId: () => 'user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) const prepared = await llm.prepareCall('deepseek-official', MODEL) config = Messages.resolveAdapterOptions({ baseURL: second.url, maxTokens: 20 }) expect(prepared.model.systemPromptUpdate).toBe('in-history') @@ -243,10 +243,11 @@ describe('direct Messages HTTP', () => { vi.stubGlobal('fetch', async () => new Response('Unauthorized', { status: 401 })) const llm = new DeepSeekAdapter({ options: () => Messages.resolveAdapterOptions({}), - resolveApiKey: () => Promise.resolve('fixture-key'), + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'fixture-key' }, + onRequestError: async () => { throw new Error('credential storage unavailable') }, + }), resolveUserId: () => 'fixture-user' as import('@deepseek-ai/dsh-anonymous-user-id').AnonymousUserId, prepareExtensions, - onRequestError: async () => { throw new Error('credential storage unavailable') }, }) await expect(chunks(llm.stream(options()))).rejects.toMatchObject({ code: 'AUTH', failure: { status: 401 } }) }) @@ -332,6 +333,21 @@ describe('Cordis provider composition', () => { expect(rejectToken.mock.calls).toEqual(provider === 'deepseek-account' ? [['fixture-token']] : []) }) + it('reports the request token when credentials change before a 401 response', async () => { + let token = 'first-login' + const { ctx } = await boot((response) => { + token = 'replacement-login' + response.writeHead(401) + response.end('Unauthorized') + }) + const rejectToken = vi.fn(async (_token: string) => {}) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => token, + rejectToken: (value: string): Promise => rejectToken(value) } as DeepSeekAccount) + await chunks(ctx.llm.stream(options({ provider: 'deepseek-account' }))) + expect(token).toBe('replacement-login') + expect(rejectToken).toHaveBeenCalledExactlyOnceWith('first-login') + }) + it('finishes the active account turn when rejected credentials publish sign-out', async () => { const { ctx } = await boot((response) => { response.writeHead(401) diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index a671607788..b3fbef8613 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -27,7 +27,7 @@ const VERSION: RequestImageAttachment = { space: 'srgb', hasAlpha: true, } -const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' } +const CONNECTION = { baseURL: 'https://api.deepseek.com', headers: { 'x-api-key': 'key' } } const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 } const NOW = 1_700_000_000_000 @@ -67,6 +67,19 @@ function uploadFetch(now: () => number = () => NOW) { } describe('DeepSeekFileStore', () => { + it('isolates credential values and header kinds while reusing reordered headers', async () => { + const remote = uploadFetch(() => NOW) + const store = new DeepSeekFileStore({ fetch: remote.fetchImpl, now: () => NOW }) + const first = await store.ensureUploaded(VERSION, CONNECTION, POLICY) + const account = await store.ensureUploaded(VERSION, { ...CONNECTION, headers: { 'x-dsh-auth-token': 'key' } }, POLICY) + const replacement = await store.ensureUploaded(VERSION, { ...CONNECTION, headers: { 'x-api-key': 'new-key' } }, POLICY) + expect(new Set([first.record.scope, account.record.scope, replacement.record.scope]).size).toBe(3) + const combined = await store.ensureUploaded(VERSION, { ...CONNECTION, headers: { a: 'one', b: 'two' } }, POLICY) + const reordered = await store.ensureUploaded(VERSION, { ...CONNECTION, headers: { b: 'two', a: 'one' } }, POLICY) + expect(reordered.record).toEqual(combined.record) + expect(remote.uploads()).toBe(4) + }) + it('reuses equivalent API roots and isolates other endpoints across invalidation and expiry', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-scope-')) roots.push(dir) @@ -77,7 +90,7 @@ describe('DeepSeekFileStore', () => { const alternate = { ...CONNECTION, baseURL: 'https://other.example' } const other = await store.ensureUploaded(VERSION, alternate, POLICY) const first = await store.ensureUploaded(VERSION, CONNECTION, POLICY) - expect(first.record.scope).toBe(deepSeekFileScope(`${CONNECTION.baseURL}/v1`, CONNECTION.apiKey)) + expect(first.record.scope).toBe(deepSeekFileScope(`${CONNECTION.baseURL}/v1`, JSON.stringify(Object.entries(CONNECTION.headers)))) expect(first.record.scope).not.toBe(other.record.scope) expect((await store.ensureUploaded(VERSION, { ...CONNECTION, baseURL: `${CONNECTION.baseURL}/v1/` }, POLICY)).record).toEqual(first.record) const reopened = new DeepSeekFileStore({ index, fetch: remote.fetchImpl, now: () => now }) @@ -406,7 +419,7 @@ describe('DeepSeekFileStore', () => { vi.spyOn(index, 'commit').mockResolvedValue({ accepted: false, record: { - scope: deepSeekFileScope(`${CONNECTION.baseURL}/v1`, CONNECTION.apiKey), + scope: deepSeekFileScope(`${CONNECTION.baseURL}/v1`, JSON.stringify(Object.entries(CONNECTION.headers))), attachmentId: VERSION.attachment.attachmentId, variantId: VERSION.variantId, fileId: DeepSeekFileId('file-api-winner'), diff --git a/packages/llm/llm-deepseek/tests/files-api.spec.ts b/packages/llm/llm-deepseek/tests/files-api.spec.ts index e0d44d2625..48126befb6 100644 --- a/packages/llm/llm-deepseek/tests/files-api.spec.ts +++ b/packages/llm/llm-deepseek/tests/files-api.spec.ts @@ -29,7 +29,7 @@ describe('DeepSeekFilesClient', () => { ['', '{"id":'].map(body => ({ operation, body })), ))('rejects malformed JSON with operation and HTTP status: %j', async ({ operation, body }) => { const fetchImpl = vi.fn(async () => new Response(body, { status: 200 })) - const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', apiKey: 'test-key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', headers: { 'x-api-key': 'test-key' }, fetch: fetchImpl }) const operations = { upload: () => client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600 }), list: () => client.list(), @@ -50,7 +50,7 @@ describe('DeepSeekFilesClient', () => { it.each([new TypeError('body transport failed'), new DOMException('body aborted', 'AbortError')])( 'preserves a non-JSON body-read failure: %s', async (failure) => { - const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', apiKey: 'test-key', + const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', headers: { 'x-api-key': 'test-key' }, fetch: async () => new Response(new ReadableStream({ start(controller) { controller.error(failure) } })), }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).rejects.toBe(failure) @@ -72,7 +72,7 @@ describe('DeepSeekFilesClient', () => { expect(form.get('expires_after[seconds]')).toBe('3600') return new Response(JSON.stringify(file())) }) as typeof fetch - const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example/custom/route///', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example/custom/route///', headers: { 'x-api-key': 'key' }, fetch: fetchImpl }) const uploaded = await client.upload({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600 }) const createdAt = Math.floor(Date.parse('2023-11-14T22:13:20.000Z') / 1_000) expect(uploaded).toEqual({ id: 'file-api-one', bytes: 3, createdAt, filename: 'image.png', expiresAt: createdAt + 3_600 }) @@ -83,7 +83,7 @@ describe('DeepSeekFilesClient', () => { ['https://provider.example/v1beta/', 'https://provider.example/v1beta/v1/files'], ])('resolves the Messages Files endpoint from %s', async (baseURL, expected) => { const fetchImpl = vi.fn(async () => new Response(JSON.stringify(file()))) - const client = new DeepSeekFilesClient({ baseURL, apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL, headers: { 'x-api-key': 'key' }, fetch: fetchImpl }) await client.upload({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, @@ -103,21 +103,21 @@ describe('DeepSeekFilesClient', () => { expect(url.pathname).toBe('/anthropic/v1/files/file-api-one') return new Response(JSON.stringify(init?.method === 'DELETE' ? { id: 'file-api-one', type: 'file_deleted' } : file())) }) as typeof fetch - const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/anthropic', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/anthropic', headers: { 'x-api-key': 'key' }, fetch: fetchImpl }) await expect(client.list({ after: DeepSeekFileId('file-before'), limit: 1_000 })).resolves.toMatchObject({ data: [{ bytes: 3 }], hasMore: false }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).resolves.toMatchObject({ id: 'file-api-one', bytes: 3 }) await expect(client.delete(DeepSeekFileId('file-api-one'))).resolves.toBeUndefined() }) it('accepts an empty Messages list with null cursors', async () => { - const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', headers: { 'x-api-key': 'key' }, fetch: async () => new Response(JSON.stringify({ data: [], first_id: null, last_id: null, has_more: false })), }) await expect(client.list()).resolves.toEqual({ data: [], hasMore: false }) }) it.each(['first_id', 'last_id'])('rejects a Messages list with a numeric %s', async (cursor) => { - const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', headers: { 'x-api-key': 'key' }, fetch: async () => new Response(JSON.stringify({ data: [], first_id: null, last_id: null, has_more: false, [cursor]: 1 })), }) await expect(client.list()).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) @@ -150,21 +150,21 @@ describe('DeepSeekFilesClient', () => { if (address === null || typeof address === 'string') throw new Error('expected a TCP server address') origins.push(`http://127.0.0.1:${address.port}`) } - const client = new DeepSeekFilesClient({ baseURL: origins[1]!, apiKey: 'redirect-test-key' }) + const client = new DeepSeekFilesClient({ baseURL: origins[1]!, headers: { 'x-api-key': 'redirect-test-key' } }) const error = await client.retrieve(DeepSeekFileId('file-api-one')).catch((cause: unknown) => cause) expect(forwarded).toEqual([]) expect(error).toMatchObject({ code: 'TRANSPORT' }) }) it.each([null, [], { type: 'wrong' }, { mime_type: null }, { created_at: 'invalid' }, { created_at: 123 }, { size_bytes: -1 }])('rejects malformed Messages file metadata %#', async (value) => { - const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', headers: { 'x-api-key': 'key' }, fetch: async () => new Response(JSON.stringify(value === null || Array.isArray(value) ? value : file(value))), }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) it('rejects a Messages deletion that returns a different file identity', async () => { - const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', headers: { 'x-api-key': 'key' }, fetch: async () => new Response(JSON.stringify({ id: 'wrong', type: 'file_deleted' })), }) await expect(client.delete(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) @@ -189,7 +189,7 @@ describe('DeepSeekFilesClient', () => { expect((blob as Blob).size).toBe(3) return new Response(JSON.stringify(file()), { status: 200 }) }) as typeof fetch - const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/', headers: { 'x-api-key': 'key' }, fetch: fetchImpl }) await expect(client.upload({ data: Uint8Array.of(1, 2, 3), @@ -218,7 +218,7 @@ describe('DeepSeekFilesClient', () => { } return new Response(JSON.stringify(file()), { status: 200 }) }) as typeof fetch - const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', headers: { 'x-api-key': 'key' }, fetch: fetchImpl }) await expect(client.list({ after: DeepSeekFileId('file-api-before'), limit: 20 })).resolves.toMatchObject({ data: [{ id: 'file-api-one' }], firstId: 'file-api-one', lastId: 'file-api-one', hasMore: false, @@ -231,7 +231,7 @@ describe('DeepSeekFilesClient', () => { const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ error: { message: 'user storage quota exceeded', type: 'invalid_request_error', code: 'file_quota' }, }), { status: 400 }))) as typeof fetch - const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', headers: { 'x-api-key': 'key' }, fetch: fetchImpl }) const error = await client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, @@ -249,7 +249,7 @@ describe('DeepSeekFilesClient', () => { ] as const)('classifies HTTP %i Files failures as %s', async (status, code) => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', - apiKey: 'key', + headers: { 'x-api-key': 'key' }, fetch: vi.fn(() => Promise.resolve(new Response('not-json', { status }))), }) await expect(client.retrieve(DeepSeekFileId('missing'))).rejects.toMatchObject({ @@ -269,7 +269,7 @@ describe('DeepSeekFilesClient', () => { ])('falls back to the HTTP status for an unstructured provider error %#', async (body) => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', - apiKey: 'key', + headers: { 'x-api-key': 'key' }, fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 400 }))), }) const error = await client.retrieve(DeepSeekFileId('missing')).catch((caught: unknown) => caught) @@ -281,7 +281,7 @@ describe('DeepSeekFilesClient', () => { const transport = new Error('socket closed') const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', - apiKey: 'key', + headers: { 'x-api-key': 'key' }, fetch: vi.fn(() => Promise.reject(transport)), }) await expect(client.retrieve(DeepSeekFileId('one'))).rejects.toMatchObject({ @@ -310,7 +310,7 @@ describe('DeepSeekFilesClient', () => { ])('rejects an invalid file object %#', async (body) => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', - apiKey: 'key', + headers: { 'x-api-key': 'key' }, fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), }) await expect(client.retrieve(DeepSeekFileId('one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) @@ -322,7 +322,7 @@ describe('DeepSeekFilesClient', () => { 3_600.5, ])('refuses invalid file expiry %s before transport', async (expiresAfterSeconds) => { const fetchImpl = vi.fn() as typeof fetch - const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', headers: { 'x-api-key': 'key' }, fetch: fetchImpl }) await expect(client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds, })).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) @@ -331,7 +331,7 @@ describe('DeepSeekFilesClient', () => { it('refuses a file larger than the upload limit before transport', async () => { const fetchImpl = vi.fn() as typeof fetch - const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', headers: { 'x-api-key': 'key' }, fetch: fetchImpl }) const data = { byteLength: MAX_FILE_UPLOAD_BYTES + 1 } as Uint8Array await expect(client.upload({ data, mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, @@ -350,7 +350,7 @@ describe('DeepSeekFilesClient', () => { ])('rejects an invalid list response %#', async (body) => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', - apiKey: 'key', + headers: { 'x-api-key': 'key' }, fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), }) await expect(client.list()).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) @@ -362,7 +362,7 @@ describe('DeepSeekFilesClient', () => { }), { status: 200 }))) vi.stubGlobal('fetch', fetchImpl) try { - const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com///', apiKey: 'key' }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com///', headers: { 'x-api-key': 'key' } }) await expect(client.list()).resolves.toEqual({ data: [], hasMore: false }) } finally { vi.unstubAllGlobals() @@ -378,7 +378,7 @@ describe('DeepSeekFilesClient', () => { ])('rejects an invalid delete response %#', async (body) => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', - apiKey: 'key', + headers: { 'x-api-key': 'key' }, fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), }) await expect(client.delete(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) @@ -387,7 +387,7 @@ describe('DeepSeekFilesClient', () => { it('authenticates Files with the raw DSH token', async () => { const client = new DeepSeekFilesClient({ - baseURL: 'https://api.deepseek.com', apiKey: 'account-token', accountCredential: true, + baseURL: 'https://api.deepseek.com', headers: { 'x-dsh-auth-token': 'account-token' }, fetch: (_url, init) => { const headers = new Headers(init?.headers) expect(headers.get('x-dsh-auth-token')).toBe('account-token') diff --git a/packages/llm/llm-deepseek/tests/files.spec.ts b/packages/llm/llm-deepseek/tests/files.spec.ts index 4e28f1a1c8..7ec3129e1c 100644 --- a/packages/llm/llm-deepseek/tests/files.spec.ts +++ b/packages/llm/llm-deepseek/tests/files.spec.ts @@ -39,7 +39,7 @@ function harness(config: Config = {}) { const prepare = vi.fn(prepareExtensions) const adapter = new DeepSeekAdapter({ options: () => resolveAdapterOptions(Object.assign({ baseURL: 'https://gateway.example/custom' }, config)), - resolveApiKey: async () => 'test-key', resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => attachments, + resolveAuth: async () => ({ headers: { 'x-api-key': 'test-key' } }), resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => attachments, resolveImageAccess: () => ({ readonlyPath: '/workspace/image.png' }), resolveFiles: () => files, prepareExtensions: prepare, }) return { adapter, ensureUploaded, invalidate, readImageRequest, prepare } @@ -56,7 +56,7 @@ describe('Messages Files requests', () => { createToolResultMessage({ callId, isError: false, content: [{ type: 'image', attachment: ref }, { type: 'image', attachment: ref }] })] await chunks(h.adapter.stream(options({ model, messages }))) expect(h.readImageRequest).toHaveBeenCalledTimes(1) - expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', apiKey: 'test-key', accountCredential: false }, expect.anything(), expect.any(AbortSignal)) + expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', headers: { 'x-api-key': 'test-key' } }, expect.anything(), expect.any(AbortSignal)) const [url, init] = fetchImpl.mock.calls[0]! expect(url).toBe('https://gateway.example/custom/v1/messages') expect(new Headers(init?.headers).get('anthropic-beta')).toBe('files-api-2025-04-14') diff --git a/packages/llm/llm-deepseek/tests/helpers.ts b/packages/llm/llm-deepseek/tests/helpers.ts index 3ddfcac7a0..606dfcfb13 100644 --- a/packages/llm/llm-deepseek/tests/helpers.ts +++ b/packages/llm/llm-deepseek/tests/helpers.ts @@ -44,7 +44,7 @@ export async function assemble(stream: AsyncIterable, model = MODEL } export function adapter(config: Config = {}) { const files = new DeepSeekFileStore() - return new DeepSeekAdapter({ options: () => resolveAdapterOptions(config), resolveApiKey: () => Promise.resolve('test-key'), resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) + return new DeepSeekAdapter({ options: () => resolveAdapterOptions(config), resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'test-key' } }), resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) } export async function server(reply: (response: ServerResponse, count: number) => void = response => response.end(sse(textEvents))) { const requests: { path: string; headers: IncomingHttpHeaders; body: Record }[] = [] diff --git a/packages/llm/llm-deepseek/tests/prepared-call.spec.ts b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts index 965b50929d..a54647ef7e 100644 --- a/packages/llm/llm-deepseek/tests/prepared-call.spec.ts +++ b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts @@ -18,7 +18,7 @@ async function endpoint(...args: Parameters) { function adapter(connection: () => DeepSeekConnectionOptions) { return new DeepSeekAdapter({ options: connection, - resolveApiKey: snapshot => Promise.resolve(`key-for-${snapshot.apiKeyEnv}`), + resolveAuth: snapshot => Promise.resolve({ headers: { 'x-api-key': `key-for-${snapshot.apiKeyEnv}` } }), resolveUserId: () => '00000000-0000-4000-8000-000000000001' as AnonymousUserId, prepareExtensions: () => Promise.resolve({ fields: {}, accept: () => Promise.resolve() }), }) diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts index af46352638..4738fe9b6a 100644 --- a/packages/llm/llm-deepseek/tests/runtime.e2e.ts +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -199,7 +199,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () return response } vi.stubGlobal('fetch', observedFetch) - const files = new Protocol.DeepSeekFilesClient({ baseURL, apiKey: key }) + const files = new Protocol.DeepSeekFilesClient({ baseURL, headers: { 'x-api-key': key } }) try { const result = await assemble(ctx, { diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts index 447ceabd3e..4c505e1fe2 100644 --- a/packages/llm/llm-deepseek/tests/runtime.spec.ts +++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts @@ -78,7 +78,7 @@ function adapterOf( const { apiKey, ...rest } = config return new DeepSeekAdapter({ options: () => resolveAdapterOptions({ ...rest }), - resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': apiKey ?? 'k' } }), resolveUserId: () => TEST_USER_ID, resolveAttachments: () => attachments, ...files === undefined ? {} : { resolveFiles: () => files }, @@ -192,7 +192,7 @@ describe('request image target', () => { const attachments = {} as AttachmentStore const adapter = new DeepSeekAdapter({ options: () => resolveAdapterOptions({ models: [{ id: 'vision', inputModalities: ['text', 'image'] }] }), - resolveApiKey: () => Promise.resolve('k'), + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'k' } }), resolveUserId: () => TEST_USER_ID, resolveAttachments: () => attachments, resolveImageAccess: (store, ref) => (store === attachments && ref === imageRef @@ -215,7 +215,7 @@ describe('DeepSeekAdapter against a mock server', () => { })) const adapter = new DeepSeekAdapter({ options: () => resolveAdapterOptions({ baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'k' } }), resolveUserId: () => TEST_USER_ID, prepareExtensions: prepareExtensions as never, }) @@ -230,7 +230,7 @@ describe('DeepSeekAdapter against a mock server', () => { const server = await mockServer([]) const base = { options: () => resolveAdapterOptions({ baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'k' } }), resolveUserId: () => TEST_USER_ID, } const failed = new DeepSeekAdapter({ @@ -256,7 +256,7 @@ describe('DeepSeekAdapter against a mock server', () => { let signalSeen: AbortSignal | undefined const adapter = new DeepSeekAdapter({ options: () => resolveAdapterOptions({ baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'k' } }), resolveUserId: () => TEST_USER_ID, prepareExtensions: ((request: { signal: AbortSignal }) => { signalSeen = request.signal @@ -321,7 +321,7 @@ describe('DeepSeekAdapter against a mock server', () => { const accept = vi.fn() const adapter = new DeepSeekAdapter({ options: () => resolveAdapterOptions({ baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'k' } }), resolveUserId: () => TEST_USER_ID, prepareExtensions: () => Promise.resolve({ fields: { dsh_test: 1 }, accept: async () => { accept() } }) as never, }) @@ -338,7 +338,7 @@ describe('DeepSeekAdapter against a mock server', () => { const failure = new Error('watermark append failed') const adapter = new DeepSeekAdapter({ options: () => resolveAdapterOptions({ baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'k' } }), resolveUserId: () => TEST_USER_ID, prepareExtensions: () => Promise.resolve({ fields: { dsh_test: 1 }, @@ -1085,12 +1085,12 @@ describe('DeepSeekAdapter against a mock server', () => { 'rejects image input for text-only model %s before credentials, image reads, or fetch', async (model) => { const server = await mockServer([]) - const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const resolveAuth = vi.fn(() => Promise.resolve({ headers: { 'x-api-key': 'k' } })) const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) const resolveAttachments = vi.fn(() => attachments.store) const adapter = new DeepSeekAdapter({ options: () => resolveAdapterOptions({ baseURL: server.url }), - resolveApiKey, + resolveAuth, resolveUserId: () => TEST_USER_ID, resolveAttachments, prepareExtensions: noExtensions, @@ -1104,7 +1104,7 @@ describe('DeepSeekAdapter against a mock server', () => { source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })], }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveApiKey).not.toHaveBeenCalled() + expect(resolveAuth).not.toHaveBeenCalled() expect(attachments.readImageRequest).not.toHaveBeenCalled() expect(server.requests).toHaveLength(0) }, @@ -1112,13 +1112,13 @@ describe('DeepSeekAdapter against a mock server', () => { it('rejects vision input without an attachment provider before credentials or fetch', async () => { const server = await mockServer([]) - const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const resolveAuth = vi.fn(() => Promise.resolve({ headers: { 'x-api-key': 'k' } })) const adapter = new DeepSeekAdapter({ options: () => resolveAdapterOptions({ baseURL: server.url, models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], }), - resolveApiKey, + resolveAuth, resolveUserId: () => TEST_USER_ID, prepareExtensions: noExtensions, }) @@ -1131,7 +1131,7 @@ describe('DeepSeekAdapter against a mock server', () => { source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })], }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveApiKey).not.toHaveBeenCalled() + expect(resolveAuth).not.toHaveBeenCalled() expect(server.requests).toHaveLength(0) }) @@ -2263,14 +2263,14 @@ describe('plugin registration and config', () => { it('resolves connection facts and the credential exactly once per stream call', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) - const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) + const resolveAuth = vi.fn(() => Promise.resolve({ headers: { 'x-api-key': 'per-request-key' } })) const resolveUserId = vi.fn(() => TEST_USER_ID) - const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId, prepareExtensions: noExtensions }) + const adapter = new DeepSeekAdapter({ options, resolveAuth, resolveUserId, prepareExtensions: noExtensions }) for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } expect(options).toHaveBeenCalledTimes(1) - expect(resolveApiKey).toHaveBeenCalledTimes(1) + expect(resolveAuth).toHaveBeenCalledTimes(1) expect(resolveUserId).toHaveBeenCalledTimes(1) expect(server.headers[0]?.['x-api-key']).toBe('per-request-key') }) diff --git a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts index 380a47fbda..cd26a3153e 100644 --- a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts +++ b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts @@ -37,7 +37,7 @@ it('continues the parent through default Messages after a reasoning-bearing cont const connection = resolveAdapterOptions({ baseURL: http.url }) const adapter = new DeepSeekAdapter({ options: () => connection, - resolveApiKey: () => Promise.resolve('test-key'), + resolveAuth: () => Promise.resolve({ headers: { 'x-api-key': 'test-key' } }), resolveUserId: () => '00000000-0000-4000-8000-000000000001' as AnonymousUserId, prepareExtensions: () => Promise.resolve({ fields: {}, accept: () => Promise.resolve() }), }) From 9987cae3eabec862d1c54b796470087a19e78dbf Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:27:10 +0800 Subject: [PATCH 25/30] fix(desktop): apply shared toast theme in welcome --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 ++ apps/desktop/README.zh.md | 2 ++ apps/desktop/renderer/welcome.css | 6 ++++-- apps/desktop/renderer/welcome.html | 2 +- apps/desktop/src/client/styles.d.ts | 2 ++ apps/desktop/src/client/welcome.tsx | 8 ++++++++ 7 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/client/styles.d.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index d414b6d5c3..6972af5326 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: f00608919c1a44441bffa57d2b601c12b47724a8 -README.zh.md: 76862075461d651a4d25235d57eb1855602ea90e +README.md: 9bed6eb21646a4021e45320281f82f93c5c22179 +README.zh.md: 67a6204ffb62b8e509a301c966a0bccfa25f8465 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f00608919c..9bed6eb216 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -52,6 +52,8 @@ Node prepares the bundled interpreters and Python libraries without a system Pyt The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md) owns shared Web behavior and Desktop adapters. The [Electron packaging and update decision](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns release identity, signing, and update qualification. +Welcome loads the shared Toast palette and shadow tokens, with system typography for body-mounted notifications. + ## Installation ownership Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 7686207546..67a6204ffb 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -52,6 +52,8 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 [薄壳决策](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 行为与 Desktop 适配。[Electron 打包与更新决策](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责发布身份、签名及更新验收。 +Welcome 加载共享 Toast 的配色和阴影变量,挂载在 body 下的通知使用系统字体。 + ## 安装归属 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css index 1e043dcee4..3b4da2defb 100644 --- a/apps/desktop/renderer/welcome.css +++ b/apps/desktop/renderer/welcome.css @@ -1,7 +1,7 @@ @import url('./assets/window-material.css'); /* Figma 2121:39336 / 2138:44626, Platform modes. Native material supplies the blur. */ -:root { +body { color-scheme: light dark; --dsw-alias-label-primary: #0f1115; --dsw-alias-label-secondary: #61666b; @@ -23,7 +23,7 @@ } @media (prefers-color-scheme: dark) { - :root { + body { --dsw-alias-label-primary: #f9fafb; --dsw-alias-label-secondary: #cfd3d6; --dsw-alias-label-tertiary: #adb2b8; @@ -52,6 +52,8 @@ body { margin: 0; overflow: hidden; background: transparent; + font-family: var(--dsw-font-family); + font-weight: 400; } #root { diff --git a/apps/desktop/renderer/welcome.html b/apps/desktop/renderer/welcome.html index 0731e3be96..f8cbeb8e15 100644 --- a/apps/desktop/renderer/welcome.html +++ b/apps/desktop/renderer/welcome.html @@ -6,8 +6,8 @@ - + diff --git a/apps/desktop/src/client/styles.d.ts b/apps/desktop/src/client/styles.d.ts new file mode 100644 index 0000000000..551c120efb --- /dev/null +++ b/apps/desktop/src/client/styles.d.ts @@ -0,0 +1,2 @@ +/** Stylesheets bundled by the standalone Welcome renderer. */ +declare module '*.css' diff --git a/apps/desktop/src/client/welcome.tsx b/apps/desktop/src/client/welcome.tsx index e60896a782..c72eec3887 100644 --- a/apps/desktop/src/client/welcome.tsx +++ b/apps/desktop/src/client/welcome.tsx @@ -1,4 +1,6 @@ /** Mount the local React renderer before Electron reveals the welcome window. */ +import '@deepseek-ai/dsh-client-ui-theme/src/styles/design-platform.css' +import '@deepseek-ai/dsh-client-ui-theme/src/styles/gradient-shadow-text.css' import { createRoot } from 'react-dom/client' import { flushSync } from 'react-dom' import { Welcome } from './WelcomePage.tsx' @@ -10,6 +12,12 @@ declare global { } } +const palette = window.matchMedia('(prefers-color-scheme: dark)') +const syncPalette = (): void => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) } +syncPalette() +palette.addEventListener('change', syncPalette) +window.addEventListener('pagehide', () => { palette.removeEventListener('change', syncPalette) }, { once: true }) + const container = document.getElementById('root') if (container === null) throw new Error('desktop welcome: missing React root') const root = createRoot(container) From 1098fa52ea7407ca910d5a4d1b8da8c7544d7124 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:00:34 +0800 Subject: [PATCH 26/30] fix(account): use configured Platform origin for account link --- packages/client/ui-settings-account/README.i18n.yaml | 4 ++-- packages/client/ui-settings-account/README.md | 2 +- packages/client/ui-settings-account/README.zh.md | 2 +- .../client/ui-settings-account/src/client/AccountSection.tsx | 2 +- .../client/ui-settings-account/tests/account.client.spec.tsx | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 6600cbedad..c3d088de8e 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/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/client/ui-settings-account/README.md -README.md: d15feec9850b7c3d1faf6a97bdc811278dc4d1a1 -README.zh.md: 113c67bc007240ea3afe4df2e94e73879872d44d +README.md: b08dc6434a6cabcb1cb6da48fc5cc46f5f0f8368 +README.zh.md: d912a92a4d8a5935c250182efd4326514784a10c diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index d15feec985..b08dc6434a 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -38,7 +38,7 @@ The account menu's Feedback entry opens the Feishu questionnaire in the system b The sidebar account menu uses the shared Menu surface and backdrop blur. The signed-in menu keeps the shared row typography; the signed-out menu carries its own wider rows and its Contact us copy for the Feedback entry. -The account card’s More account information link opens `https://platform.deepseek.com` in the system browser. +The account card’s More account information link opens the root of the Host-provided Platform usage URL in the system browser, following `platformOrigin`. Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed impact queries still open confirmation with an explicit unknown-task warning; failed sign-out keeps the dialog available for retry. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 113c67bc00..d912a92a4d 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -38,7 +38,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 侧边栏账号菜单使用公共 Menu 的表面和背景模糊。已登录菜单沿用公共菜单项排版;未登录菜单使用自己的较宽菜单项,并将“意见反馈”入口的文案改为“联系我们”。 -账号卡片的“更多账号信息”链接通过系统浏览器打开 `https://platform.deepseek.com`。 +账号卡片的“更多账号信息”链接通过系统浏览器打开 Host 提供的 Platform 用量 URL 的根路径,遵循 `platformOrigin` 配置。 退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。影响查询失败仍打开确认弹窗,并明确提示无法确认任务状态;退登失败保留弹窗以便重试。 diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index b8482330fe..701d2a775f 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -121,7 +121,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, refresh {status}
- {signedIn && + {signedIn && {t('accountInfo')} }
diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 3a8eb98383..05dcd079d2 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -359,11 +359,11 @@ it.each([[], [{ currency: 'CNY' as const, balance: '0.00' }, { currency: 'USD' a }, ) -it('opens more account information externally without invoking the embedded Platform bridge', () => { +it('opens more account information at the configured Platform origin without invoking the embedded bridge', () => { const platform: PlatformBridge = { open: vi.fn(), close: vi.fn(), setBounds: vi.fn() } mount({ status: 'credential-stored', attempt: null }, en, undefined, platform) const link = screen.getByRole('link', { name: en.accountInfo }) - expect(link.getAttribute('href')).toBe('https://platform.deepseek.com') + expect(link.getAttribute('href')).toBe('http://localhost:8081/') expect(link.getAttribute('target')).toBe('_blank') expect(link.getAttribute('rel')).toBe('noopener noreferrer') fireEvent.click(link) From 3cf49f26ab91c5c1b7c2e7a232e62c562162367f Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:52:16 +0800 Subject: [PATCH 27/30] fix(account): cover live notices and satisfy CI checks --- apps/desktop/tests/account-backend.spec.ts | 5 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 67 +++++++++++-------- docs/module-graph.zh.md | 67 +++++++++++-------- .../tests/session-models.host.spec.ts | 4 +- .../tests/account.client.spec.tsx | 6 ++ .../tests/apply.client.spec.ts | 25 +++++++ .../llm/llm-deepseek-api-key/src/index.ts | 4 +- 8 files changed, 120 insertions(+), 62 deletions(-) diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index 48dd4e3133..e82dd5601b 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -38,7 +38,7 @@ it('receives live expiry separately from account snapshots', async () => { await once(server, 'listening') onTestFinished(async () => { for (const client of server.clients) client.terminate() - await new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()) }) + await new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) }) }) const address = server.address() if (typeof address === 'string' || address === null) throw new Error('missing server address') @@ -56,7 +56,8 @@ it('receives live expiry separately from account snapshots', async () => { const opened = Promise.withResolvers() const socket = [...server.clients][0]! socket.on('message', (data) => { - const frame = JSON.parse(data.toString()) as { endpoint: string; streamId: string } + if (!Buffer.isBuffer(data)) throw new Error('expected a Buffer WebSocket frame') + const frame = JSON.parse(data.toString('utf8')) as { endpoint: string; streamId: string } streams.set(frame.endpoint, frame.streamId) if (streams.size === 2) opened.resolve(undefined) }) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 32276ebf2e..aef79e8af5 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: bff30ef9bc6b2ad54bbf8913a52b30a58249e753 -module-graph.zh.md: 683dc6ce9f0f3e134043ec9dc77845811acb2057 +module-graph.md: 946692fd4b0528444391fca6aa8bffec46eb455c +module-graph.zh.md: 5048c2deab70b62ce9795dc7f6f9e14bae2ca732 diff --git a/docs/module-graph.md b/docs/module-graph.md index bff30ef9bc..946692fd4b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -29,6 +29,8 @@ flowchart TD pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] + pkg_llm_deepseek_account["llm-deepseek-account"] + pkg_llm_deepseek_api_key["llm-deepseek-api-key"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] @@ -428,7 +430,6 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants - pkg_deepseek_account --> pkg_brand pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse @@ -486,10 +487,6 @@ flowchart TD pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt - pkg_deepseek_account_platform --> pkg_authorization - pkg_deepseek_account_platform --> pkg_credentials - pkg_deepseek_account_platform --> pkg_deepseek_account - pkg_deepseek_account_platform --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver @@ -544,6 +541,15 @@ flowchart TD pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain pkg_workspace --> pkg_typert_protocol + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -596,18 +602,10 @@ flowchart TD pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout - pkg_llm_deepseek --> pkg_agent - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_account - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_fs - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_timeout + pkg_llm_deepseek_api_key --> pkg_credentials + pkg_llm_deepseek_api_key --> pkg_launch_environment + pkg_llm_deepseek_api_key --> pkg_llm + pkg_llm_deepseek_api_key --> pkg_llm_deepseek pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -630,9 +628,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web - pkg_api_account_controller --> pkg_agent - pkg_api_account_controller --> pkg_deepseek_account - pkg_api_account_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -643,6 +638,9 @@ flowchart TD pkg_tmux_context --> pkg_session pkg_tmux_context --> pkg_session_projection pkg_tmux_context --> pkg_shell + pkg_deepseek_account --> pkg_agent + pkg_deepseek_account --> pkg_brand + pkg_deepseek_account --> pkg_llm pkg_commands --> pkg_agent pkg_commands --> pkg_attachment pkg_commands --> pkg_brand @@ -691,6 +689,10 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session + pkg_llm_deepseek_account --> pkg_deepseek_account + pkg_llm_deepseek_account --> pkg_launch_environment + pkg_llm_deepseek_account --> pkg_llm + pkg_llm_deepseek_account --> pkg_llm_deepseek pkg_tools --> pkg_agent pkg_tools --> pkg_invariants pkg_tools --> pkg_llm @@ -713,11 +715,18 @@ flowchart TD pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy + pkg_api_account_controller --> pkg_agent + pkg_api_account_controller --> pkg_deepseek_account + pkg_api_account_controller --> pkg_typert_protocol pkg_compaction --> pkg_brand pkg_compaction --> pkg_commands pkg_compaction --> pkg_invariants pkg_compaction --> pkg_llm pkg_compaction --> pkg_session + pkg_deepseek_account_platform --> pkg_authorization + pkg_deepseek_account_platform --> pkg_credentials + pkg_deepseek_account_platform --> pkg_deepseek_account + pkg_deepseek_account_platform --> pkg_host_webserver pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_session @@ -1205,6 +1214,7 @@ flowchart TD pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands + pkg_api_session_controller --> pkg_credentials pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_fs pkg_api_session_controller --> pkg_llm @@ -1216,6 +1226,7 @@ flowchart TD pkg_api_session_controller --> pkg_session_projection_cache pkg_api_session_controller --> pkg_session_query pkg_api_session_controller --> pkg_session_title + pkg_api_session_controller --> pkg_settings pkg_api_session_controller --> pkg_skill pkg_api_session_controller --> pkg_subagent pkg_api_session_controller --> pkg_typert_protocol @@ -1311,7 +1322,7 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_agent pkg_sdk_jsonrpc_server --> pkg_attachment pkg_sdk_jsonrpc_server --> pkg_llm - pkg_sdk_jsonrpc_server --> pkg_llm_deepseek + pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key pkg_sdk_jsonrpc_server --> pkg_scope pkg_sdk_jsonrpc_server --> pkg_sdk_protocol pkg_sdk_jsonrpc_server --> pkg_session @@ -1457,7 +1468,6 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`brand`](../packages/util/brand) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | @@ -1489,7 +1499,6 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | -| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | @@ -1513,6 +1522,7 @@ flowchart TD | [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | @@ -1527,15 +1537,15 @@ flowchart TD | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`agent`](../packages/core/agent), [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | -| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | +| [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1546,11 +1556,14 @@ flowchart TD | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | @@ -1636,7 +1649,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | @@ -1649,6 +1662,6 @@ flowchart TD | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 683dc6ce9f..5048c2deab 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -31,6 +31,8 @@ flowchart TD pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] + pkg_llm_deepseek_account["llm-deepseek-account"] + pkg_llm_deepseek_api_key["llm-deepseek-api-key"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] @@ -430,7 +432,6 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants - pkg_deepseek_account --> pkg_brand pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse @@ -488,10 +489,6 @@ flowchart TD pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt - pkg_deepseek_account_platform --> pkg_authorization - pkg_deepseek_account_platform --> pkg_credentials - pkg_deepseek_account_platform --> pkg_deepseek_account - pkg_deepseek_account_platform --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver @@ -546,6 +543,15 @@ flowchart TD pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain pkg_workspace --> pkg_typert_protocol + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -598,18 +604,10 @@ flowchart TD pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout - pkg_llm_deepseek --> pkg_agent - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_account - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_fs - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_timeout + pkg_llm_deepseek_api_key --> pkg_credentials + pkg_llm_deepseek_api_key --> pkg_launch_environment + pkg_llm_deepseek_api_key --> pkg_llm + pkg_llm_deepseek_api_key --> pkg_llm_deepseek pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -632,9 +630,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web - pkg_api_account_controller --> pkg_agent - pkg_api_account_controller --> pkg_deepseek_account - pkg_api_account_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -645,6 +640,9 @@ flowchart TD pkg_tmux_context --> pkg_session pkg_tmux_context --> pkg_session_projection pkg_tmux_context --> pkg_shell + pkg_deepseek_account --> pkg_agent + pkg_deepseek_account --> pkg_brand + pkg_deepseek_account --> pkg_llm pkg_commands --> pkg_agent pkg_commands --> pkg_attachment pkg_commands --> pkg_brand @@ -693,6 +691,10 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session + pkg_llm_deepseek_account --> pkg_deepseek_account + pkg_llm_deepseek_account --> pkg_launch_environment + pkg_llm_deepseek_account --> pkg_llm + pkg_llm_deepseek_account --> pkg_llm_deepseek pkg_tools --> pkg_agent pkg_tools --> pkg_invariants pkg_tools --> pkg_llm @@ -715,11 +717,18 @@ flowchart TD pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy + pkg_api_account_controller --> pkg_agent + pkg_api_account_controller --> pkg_deepseek_account + pkg_api_account_controller --> pkg_typert_protocol pkg_compaction --> pkg_brand pkg_compaction --> pkg_commands pkg_compaction --> pkg_invariants pkg_compaction --> pkg_llm pkg_compaction --> pkg_session + pkg_deepseek_account_platform --> pkg_authorization + pkg_deepseek_account_platform --> pkg_credentials + pkg_deepseek_account_platform --> pkg_deepseek_account + pkg_deepseek_account_platform --> pkg_host_webserver pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_session @@ -1207,6 +1216,7 @@ flowchart TD pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands + pkg_api_session_controller --> pkg_credentials pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_fs pkg_api_session_controller --> pkg_llm @@ -1218,6 +1228,7 @@ flowchart TD pkg_api_session_controller --> pkg_session_projection_cache pkg_api_session_controller --> pkg_session_query pkg_api_session_controller --> pkg_session_title + pkg_api_session_controller --> pkg_settings pkg_api_session_controller --> pkg_skill pkg_api_session_controller --> pkg_subagent pkg_api_session_controller --> pkg_typert_protocol @@ -1313,7 +1324,7 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_agent pkg_sdk_jsonrpc_server --> pkg_attachment pkg_sdk_jsonrpc_server --> pkg_llm - pkg_sdk_jsonrpc_server --> pkg_llm_deepseek + pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key pkg_sdk_jsonrpc_server --> pkg_scope pkg_sdk_jsonrpc_server --> pkg_sdk_protocol pkg_sdk_jsonrpc_server --> pkg_session @@ -1459,7 +1470,6 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`brand`](../packages/util/brand) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | @@ -1491,7 +1501,6 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | -| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | @@ -1515,6 +1524,7 @@ flowchart TD | [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | @@ -1529,15 +1539,15 @@ flowchart TD | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`agent`](../packages/core/agent), [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | -| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | +| [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1548,11 +1558,14 @@ flowchart TD | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | @@ -1638,7 +1651,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | @@ -1651,6 +1664,6 @@ flowchart TD | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index b0fbf788a1..0a213ca360 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -863,10 +863,10 @@ it('initializes the account model without reasoning metadata and rejects an empt }) }) -it('reports a non-Error catalog rejection as an unavailable selection', async () => { +it.each([new Error('catalog disconnected'), 'catalog disconnected'])('reports catalog rejection as an unavailable selection: %s', async (failure) => { const { ctx } = await harness() const { modelAvailable } = await import('../src/catalog.ts') - const read = vi.spyOn(ctx.llm, 'listModels').mockRejectedValueOnce('catalog disconnected') + const read = vi.spyOn(ctx.llm, 'listModels').mockRejectedValueOnce(failure) try { await expect(modelAvailable(ctx, { provider: 'deepseek-official', model: 'deepseek-chat' })) .rejects.toMatchObject({ code: 'session/model-unavailable', message: 'catalog disconnected' }) diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 05dcd079d2..e574d3a284 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -503,6 +503,8 @@ it('reports resize failure, ignores late native failures, and tolerates a remove }) it.each([en, zh])('shows live model sign-in guidance without replaying it after remount', async (copy) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const operations = operationsOf({ status: 'signed-out', attempt: null }) let listener: (() => void) | undefined const unsubscribe = vi.fn(() => { listener = undefined }) @@ -518,6 +520,10 @@ it.each([en, zh])('shows live model sign-in guidance without replaying it after expect(screen.getByRole('alert').textContent).toBe(copy.modelSignInRequired) act(() => { listener?.() }) expect(screen.getAllByRole('alert')).toHaveLength(1) + await act(async () => { await vi.advanceTimersByTimeAsync(4000) }) + expect(screen.queryByRole('alert')).toBeNull() + act(() => { listener?.() }) + expect(screen.getByRole('alert').textContent).toBe(copy.modelSignInRequired) view.unmount() expect(unsubscribe).toHaveBeenCalledOnce() render(element) diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 344467bc10..89230b1e9f 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -185,3 +185,28 @@ it('reads the account task impact and reports a refused query', async ({ start, mock.remote.account.hasRunningAccountTasks.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'offline', {}) }) await expect(actions.hasRunningAccountTasks()).rejects.toThrow('account task query failed') }, 60_000) + + +it('forwards live account notices and removes their subscriptions', async ({ start }) => { + vi.stubGlobal('dshDesktop', {}) + const c = await start() + const actions = operations(c) + const expired = vi.fn() + const unavailable = vi.fn() + const offExpired = actions.subscribeSessionExpired!(expired) + const offUnavailable = actions.subscribeModelSignInRequired!(unavailable) + for (const event of ['deepseek-account/session-expired', 'deepseek-account/model-sign-in-required']) { + c.mock.streams.push('$events', { type: 'emit', event, args: [] }) + } + await c.flush() + expect(expired).toHaveBeenCalledOnce() + expect(unavailable).toHaveBeenCalledOnce() + offExpired() + offUnavailable() + for (const event of ['deepseek-account/session-expired', 'deepseek-account/model-sign-in-required']) { + c.mock.streams.push('$events', { type: 'emit', event, args: [] }) + } + await c.flush() + expect(expired).toHaveBeenCalledOnce() + expect(unavailable).toHaveBeenCalledOnce() +}) diff --git a/packages/llm/llm-deepseek-api-key/src/index.ts b/packages/llm/llm-deepseek-api-key/src/index.ts index 94280483e0..cf039f9a0a 100644 --- a/packages/llm/llm-deepseek-api-key/src/index.ts +++ b/packages/llm/llm-deepseek-api-key/src/index.ts @@ -39,9 +39,9 @@ export function apply(ctx: Context, config: Config): void { registerDeepSeekProvider(ctx, PROVIDER, { options, providerName: 'DeepSeek', resolveAuth: async connection => ({ headers: { 'x-api-key': await resolveApiKey(connection) } }), - discoverModels: async (provider) => { + discoverModels: (provider) => { const connection = options() - return connection.models.map(model => catalogModelInfo(provider, model)) + return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model))) }, }) } From eff44a957cd7d2697ccf70350674a326bafd7604 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:08:21 +0800 Subject: [PATCH 28/30] test(headless): isolate API-key credential snapshots --- .../cli/tests/profiles/headless/credentials-snapshot.patch.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml b/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml index 148d357d23..8293d9919f 100644 --- a/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml @@ -9,6 +9,9 @@ - id: llm-deepseek disabled: true +- id: llm-deepseek-account + disabled: true + - id: agent-loop config: agents: From 71f0713b8680a8539a41901915bdbcf18fdc49d9 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:18:17 +0800 Subject: [PATCH 29/30] fix(llm): align provider packages with release version --- packages/llm/llm-deepseek-account/package.json | 2 +- packages/llm/llm-deepseek-api-key/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/llm/llm-deepseek-account/package.json b/packages/llm/llm-deepseek-account/package.json index f96077c86e..96166d4c33 100644 --- a/packages/llm/llm-deepseek-account/package.json +++ b/packages/llm/llm-deepseek-account/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek-account", "description": "DeepSeek account provider authentication and discovery", - "version": "0.1.7-alpha.2", + "version": "0.1.7-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek-api-key/package.json b/packages/llm/llm-deepseek-api-key/package.json index c0bce4980c..c932986179 100644 --- a/packages/llm/llm-deepseek-api-key/package.json +++ b/packages/llm/llm-deepseek-api-key/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek-api-key", "description": "DeepSeek api-key provider authentication and discovery", - "version": "0.1.7-alpha.2", + "version": "0.1.7-rc.1", "publishConfig": { "access": "public" }, From 9a9948bc83a7732db18b69d59958e43df5ae7494 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:33:16 +0800 Subject: [PATCH 30/30] test(web): align unavailable model and cold session expectations --- .../tests/deepseek-messages-settings.e2e.ts | 7 +++-- apps/web/tests/default-model.e2e.ts | 26 +++++-------------- .../default-model/unselected.expected.md | 8 +++--- .../tests/onboarding-deepseek-config.e2e.ts | 3 ++- apps/web/tests/reference-composer.e2e.ts | 6 ++--- 5 files changed, 20 insertions(+), 30 deletions(-) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 93aa400f57..6543428836 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -90,8 +90,11 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) await page.reload({ waitUntil: 'load' }) const input = page.locator('[data-composer-input]').first() - await expect.poll(() => input.getAttribute('contenteditable')).toBe('false') - await page.getByRole('button', { name: '请选择模型', exact: true }).click() + const trigger = page.getByRole('button', { name: /^选择模型.*deepseek-official\/deepseek-v4-flash/ }) + await trigger.waitFor() + await expect.poll(() => input.getAttribute('contenteditable')).toBe('true') + await trigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click() await expect.poll(() => input.getAttribute('contenteditable')).toBe('true') await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-official') diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 08f8505d7f..04cbd9b80f 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -127,7 +127,7 @@ describe('web e2e: the composer model switch is the default for later sessions', expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('goes inert when the route the default names stops being served', async () => { + it('retains the saved route and editable composer when its provider is removed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked')) const box = page.locator('[data-composer-input]').first() await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true) @@ -139,28 +139,14 @@ describe('web e2e: the composer model switch is the default for later sessions', baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, } }) - await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false) - const unset = page.getByRole('button', { name: '请选择模型', exact: true }) - await unset.waitFor() - expect(await unset.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400') - expect(await page.getByRole('button', { name: '发送消息', exact: true }).isEnabled()).toBe(false) + const seat = page.getByRole('button', { name: new RegExp(`^选择模型.*${ROUTE}/${MODEL}`) }) + await seat.waitFor() + expect(await box.isEnabled()).toBe(true) + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ provider: ROUTE, model: MODEL }) const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode()) - - // The block is an affordance; the refusal is the Host's. A client that - // never disabled anything still cannot start a turn on a dead route. - await expect(scaffold.ctx.sessionController.prompt({ - requestId: 'default-model-refused' as never, - sessionId: SessionId(await createSession('default-model-refusal')), - mode: 'queue', - content: [{ type: 'text', text: 'hi' }], - }, new AbortController().signal)).rejects.toMatchObject({ code: 'session/model-unavailable' }) - - // The way out stays open. Locking the model seat with everything else - // would leave the composer asking for the one thing it prevents. - const seat = page.getByRole('button', { name: '请选择模型', exact: true }) - expect(await seat.isEnabled()).toBe(true) await seat.click() + await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio').first().click() await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/expected/default-model/unselected.expected.md b/apps/web/tests/expected/default-model/unselected.expected.md index 6228bea41c..8845f5196b 100644 --- a/apps/web/tests/expected/default-model/unselected.expected.md +++ b/apps/web/tests/expected/default-model/unselected.expected.md @@ -1,5 +1,5 @@ -- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" [disabled] -- button "添加文件或调用指令" [disabled] -- button "访问模式,当前:工作区内修改" [disabled]: 工作区内修改 -- button "请选择模型" +- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" +- button "添加文件或调用指令" +- button "访问模式,当前:工作区内修改": 工作区内修改 +- button "选择模型,当前 acme-gateway/acme-large": acme-gateway/acme-large - button "发送消息" [disabled] diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index d0e5ddd582..f3b3b48542 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -275,9 +275,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup // trigger — on the page; the scaffold boots without one. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e', false) - const modelTrigger = page.getByRole('button', { name: '请选择模型', exact: true }) + const modelTrigger = page.getByRole('button', { name: /^选择模型.*deepseek-official\// }) await modelTrigger.waitFor({ timeout: 10_000 }) await modelTrigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() expect(await page.getByText('Configured Flash', { exact: true }).count()).toBe(0) await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 }) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index ed1bd9fa82..d4518210b4 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -24,6 +24,7 @@ import { compareOrRefreshGolden, launchWebScaffold, seedSession, + readPersistedEvents, watchConsole, webSnapshotMode, type WebScaffold, @@ -403,9 +404,8 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await target.waitFor({ timeout: 15_000 }) await target.click() await page.locator('[data-chat-flow-kind="user"]').filter({ hasText: 'Research notes' }).waitFor({ timeout: 15_000 }) - const session = scaffold.ctx.sessions.get(SessionId(TARGET_SESSION_ID)) - if (session === undefined) throw new Error('reference target session is unavailable') - const inputs = session.snapshotEvents().filter(event => event.type === 'user/message') + const events = await readPersistedEvents(scaffold, SessionId(TARGET_SESSION_ID)) + const inputs = events.filter(event => event.type === 'user/message') expect(inputs.map(event => event.data.source.kind)).toEqual(['user', 'session-reference']) expect(inputs[0]?.seq).toBeLessThan(inputs[1]!.seq) expect(JSON.stringify(inputs[1]?.data)).toContain('snapshot')