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 60eec8a71e..f71a656550 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: 458e020c2000fd3db5ee9806e99d4b653710cb1e -2026-09-14-deepseek-account-login.zh.md: 2fb59009302309a67e2c3ec99050685ba39b4212 +2026-09-14-deepseek-account-login.md: 165cd2d0a2fbe3a4a71d3ce31de69c5005c639b2 +2026-09-14-deepseek-account-login.zh.md: 5e09c0a17cc933b46aa6498c70d86fdefd26d21c 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 458e020c20..165cd2d0a2 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 @@ -30,7 +30,7 @@ Putting the protocol in Electron duplicates the implementation for Web consumers Sign out removes the local grant before starting Platform POST /auth-api/v0/users/logout with the captured token. Remote failure never restores login. The provider retries at most five times after the initial request, with configurable exponential backoff starting at one second by default. Retry jobs retain only their old token, cannot change later login state, and end at provider shutdown without durable storage. Local credential deletion must succeed before publishing signed-out state. Sign out belongs to the sidebar account menu; Account settings owns profile, balance and sign-in. Both read one plugin-owned Host stream through framework hooks. -API keys and account grants remain separately stored. Account tokens have no expiry or refresh flow; exhausted logout retries can leave the removed token valid remotely. Provider initialization discards grants from a different configured Platform issuer locally, without remote revocation, so switching environments starts signed out instead of aborting Desktop startup. API keys and device identity survive this cleanup. Profile and balance query failures preserve login state; authorization-attempt deadlines apply only before token issuance. The same grant authenticates Platform current and get_user_summary queries on its configured issuer origin. Host preserves Platform-masked contact details and drops response tokens; account changes invalidate pending results. UI shows normal_wallets recharge balances and positive bonus_wallets in separate rows, so promotional credit is never presented as recharge funds. +API keys and account grants remain separately stored. Account tokens have no proactive refresh flow; exhausted logout retries can leave the removed token valid remotely. Provider initialization discards grants from a different configured Platform issuer locally, without remote revocation, so switching environments starts signed out instead of aborting Desktop startup. API keys and device identity survive this cleanup. Profile and balance HTTP 401 responses clear the rejected credential; other query failures preserve login state; authorization-attempt deadlines apply only before token issuance. The same grant authenticates Platform current and get_user_summary queries on its configured issuer origin. Host preserves Platform-masked contact details and drops response tokens; account changes invalidate pending results. UI shows normal_wallets recharge balances and positive bonus_wallets in separate rows, so promotional credit is never presented as recharge funds. The account plugin supplies choice, waiting, failure and timeout dialogs through settings.models.sign-in. The model package owns credential readiness and the existing API-key editor; the settings shell coordinates explicit reopening so login and API-key onboarding do not mount competing dialogs. 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 2fb5900930..5e09c0a17c 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 @@ -30,7 +30,7 @@ Desktop profile 为 Host API 请求头提供原生平台信息。账号与更新 退出登录先删除本地授权,再使用捕获的 token 调用 Platform POST /auth-api/v0/users/logout。远程失败不会恢复登录态。首次请求失败后,提供者最多重试五次,使用可配置的指数退避,默认从一秒开始。重试任务只保留旧 token,不改变后续登录态,并在提供者关闭时结束,不持久化。本地凭证删除成功后才发布已退出状态。退出登录位于侧边栏账号菜单;账号设置负责资料、余额和登录。两者通过框架 hook 读取插件持有的同一条 Host 状态流。 -API Key 与账号记录独立保存。账号 token 没有过期或刷新流程;退登重试耗尽后,已从本地删除的 token 在远端仍可能有效。提供者初始化时在本地丢弃与配置的 Platform 来源不同的授权,不调用远端撤销,使环境切换以未登录态启动,而不导致 Desktop 启动失败。API Key 和设备标识保留。资料与余额查询失败保留登录态;授权尝试的有效期仅适用于 token 签发前。同一授权 token 可认证已配置签发来源上的 Platform current 和 get_user_summary 查询。Host 原样保留 Platform 脱敏后的联系方式并丢弃响应 token;账号变化使未完成结果失效。UI 分行显示 normal_wallets 充值余额与正额 bonus_wallets 赠金余额,避免将赠金额度显示为充值资金。 +API Key 与账号记录独立保存。账号 token 没有主动刷新流程;退登重试耗尽后,已从本地删除的 token 在远端仍可能有效。提供者初始化时在本地丢弃与配置的 Platform 来源不同的授权,不调用远端撤销,使环境切换以未登录态启动,而不导致 Desktop 启动失败。API Key 和设备标识保留。资料与余额接口的 HTTP 401 响应清除被拒绝的凭据,其他查询错误保留登录态;授权尝试的有效期仅适用于 token 签发前。同一授权 token 可认证已配置签发来源上的 Platform current 和 get_user_summary 查询。Host 原样保留 Platform 脱敏后的联系方式并丢弃响应 token;账号变化使未完成结果失效。UI 分行显示 normal_wallets 充值余额与正额 bonus_wallets 赠金余额,避免将赠金额度显示为充值资金。 账号插件通过 settings.models.sign-in 提供选择、等待、失败和超时对话框。模型包保留凭证就绪检查和现有 API Key 编辑器;设置外壳协调显式重开,避免登录与 API Key 引导同时挂载冲突的对话框。 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..09412b4916 --- /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: 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 new file mode 100644 index 0000000000..6b8ef842c5 --- /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. 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). + +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 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 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 `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 new file mode 100644 index 0000000000..588dd38bf9 --- /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 最近的 `Session.requestContext()`,不维护额外提供方字段。该已记录值在工具和重试阶段保留,随下一次绑定请求更新;空闲 Agent 不计入。新轮次首次准备期间,它仍可能指向上一轮的账号路由,因此退登也可能打断尚未绑定新请求的 API key 轮次。没有历史上下文的首请求若缺少账号凭据,会在传输前失败。 + +本地退登成功后发布账号事件。Platform 账号提供方安装账号模块的监听器,遍历 Agent 注册表,通过 `Agent.cancel` 取消匹配的账号路由并保留收件箱。账号控制器的退登确认复用同一判断函数。每个已注册子代理独立分类;生命周期绑定到已取消父代理的子代理仍受现有父级取消规则约束。现有 HTTP 信号将取消传递到传输层。 + +## Alternatives considered + +历史提供方集合会在轮次切换到 API Key 路由后仍错误取消它。当前设置会误判更早的在途请求。额外的提供方字段或模块级全局映射重复保存请求上下文中已有的信息。登录级取消信号重复现有 Agent 与 HTTP 取消链路。 + +## Consequences + +模型选择展示两条路由,即使存在 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 5e309e3982..b9513d0184 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/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..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: @@ -24,6 +27,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/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/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index d25e3c5ef8..6a224c8ba2 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: ec3f2f44d5e49db00bd595588a0594bcee0a1538 -README.zh.md: 50fba340a0edc114a75d4b6363b8e6c42be0c45f +README.md: fb83b0fc66c281a596d743b561e0ee33f32b2c04 +README.zh.md: 490eff4b35a3e7b52108ff529ed9d3b432f68bb3 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ec3f2f44d5..fb83b0fc66 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. Both Welcome and the workspace display the localized expiry notice. + Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description. Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools. @@ -50,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. @@ -410,7 +414,9 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i - 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. - Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `packages/skill/tool-workspace-dependencies/src/index.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime. -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. 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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 50fba340a0..490eff4b35 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 时保持工作区打开。主动退出登录遵循相同规则。Welcome 和工作区均显示本地化的登录失效提示。 + 桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。 按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。 @@ -50,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。 @@ -410,7 +414,9 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。 - 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`packages/skill/tool-workspace-dependencies/src/index.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。 -登录会在系统浏览器中打开配置的平台页面。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 不会重复提示;主动退登和冷启动不会生成该通知。 登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。 diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css index 3d5fc88460..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 { @@ -192,6 +194,7 @@ body { top: calc(100% + 16px); left: 50%; transform: translateX(-50%); + white-space: nowrap; min-width: 58px; height: 28px; padding: 4px 10px; @@ -245,7 +248,7 @@ body { .key-heading h1 { margin: 0; font-size: 20px; - font-weight: 300; + font-weight: 400; line-height: 32px; letter-spacing: 0.01em; } 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/account-backend.ts b/apps/desktop/src/account-backend.ts index 0db791f631..d816de55fc 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -63,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 } /** @@ -80,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/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index 179bc4ef09..b7f4069bcb 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -1,6 +1,7 @@ /** Desktop welcome presentation; account and credential operations stay in the preload. */ import { useEffect, useRef, useState } from 'react' import type { FormEvent } from 'react' +import { Toast } from '@deepseek-ai/dsh-client-ui-primitives/src/Toast.tsx' import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives/src/StateDot.tsx' import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' import type { WelcomeApi } from '../welcome-api.ts' @@ -9,11 +10,13 @@ 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. */ export function Welcome({ api }: { api: WelcomeApi }) { const { messages: m } = api + const [expiryNotice, setExpiryNotice] = useState(false) const [page, setPage] = useState('entry') const pageRef = useRef('entry') const [attempt, setAttempt] = useState(null) @@ -42,15 +45,24 @@ 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(() => { 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++ + takeNotice() showAccount(state) }) return () => { mounted.current = false; stop() } @@ -156,6 +168,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { const heading = page === 'entry' ? 'welcome-heading' : page === 'key' ? 'key-title' : 'auth-status' return <> + {expiryNotice && { setExpiryNotice(false) }} />} 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..d364f41ede --- /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, IconCloseOutlineRegular, 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 | 'unknown' + 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 === '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 84fc5600f9..5817b0d461 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', 'theme'] +export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme'] /** Register account UI only in the Desktop renderer. @param ctx - client plugin context. */ export function apply(ctx: Context): void { if (!('dshDesktop' in globalThis)) return @@ -72,13 +72,25 @@ export function apply(ctx: Context): void { for await (const frame of stream) { revision++ refreshing = undefined + 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 }) frame.accept() + if (initialize) void (async () => { + try { + 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' }) + } + })() void refresh() } })().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, contactUs() { @@ -117,6 +129,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 2e4b40474d..2893f9a842 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -1,16 +1,21 @@ /** 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.', + 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.', 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', cancel: 'Cancel', open: 'Open browser', + 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', completing: 'Completing sign in…', expired: 'Sign in expired. Try again.', failed: 'Could not complete the operation. Try again.', @@ -25,17 +30,22 @@ 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: '登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。', + loginTitle: '开始使用', loginDescription: '登录 DeepSeek 账号,或添加 API Key,即可开始使用。你的项目和文件保存在本地。', browserTitle: '等待登录', browserPrompt: '没有自动打开新页面?', copyLink: '复制登录链接', copiedLink: '链接已复制', copyFailed: '复制失败', browserDescription: ',手动打开登录页完成登录。', timeoutTitle: '登录已超时', timeoutDescription: '请重新登录后继续操作。', failureTitle: '登录失败', platformFailed: '操作未完成,请重试', platformRetry: '重试', loading: '加载中…', backToHarness: '返回 DeepSeek Harness', - settings: '设置', contactUs: '意见反馈', menu: '账号菜单', + settings: '设置', contactUs: '意见反馈', contactUsSignedOut: '联系我们', menu: '账号菜单', nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录', - signIn: '登录', signOut: '退出登录', cancel: '取消', open: '打开浏览器', + signIn: '登录', signOut: '退出登录', + signOutUnknownDescription: '暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?', + 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 4e4351f411..e574d3a284 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -27,6 +27,7 @@ function operationsOf(state: Omit, details?: Partial themeOf('light'), 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()), @@ -65,13 +66,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, '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( { if (running === 'unknown') throw new Error('offline'); return running }} useAccount={selector => 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} />) @@ -86,10 +88,34 @@ 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 === '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() }) +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() @@ -120,7 +146,7 @@ it('reports a failed start in the login dialog, not as a sidebar alert', async ( await expect(`${document.body.textContent}\n`).toMatchFileSnapshot('./expected/login-failed-en.txt') }) -it('keeps the sidebar alert for a failed sign-out', async () => { +it('keeps the confirmation dialog open after a failed sign-out', async () => { const operations = mount({ status: 'credential-stored', attempt: null }) cleanup() const { AccountMenu } = await import('../src/client/AccountMenu.tsx') @@ -131,6 +157,7 @@ it('keeps the sidebar alert for a failed sign-out', async () => { openOnboarding={() => {}} openSettings={() => {}} t={key => key in en ? en[key as AccountKey] : key} />) fireEvent.click(screen.getByRole('button', { name: en.menu })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: en.signOut })) }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signOut })) }) expect(screen.getByRole('alert').textContent).toBe(en.failed) }) @@ -332,17 +359,43 @@ 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) expect(platform.open).not.toHaveBeenCalled() }) +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 }, copy) + cleanup() + const { AccountMenu } = await import('../src/client/AccountMenu.tsx') + let expire: (() => 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} /> + 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) => { const operations = mount({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase, authorizeUrl: 'https://example.test/authorize' } }) expect(screen.getByRole('link', { name: en.open }).getAttribute('href')).toBe('https://example.test/authorize?theme=light') @@ -448,3 +501,31 @@ 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) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + 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) + 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) + expect(screen.queryByRole('alert')).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 0d3a17ec98..89230b1e9f 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -175,3 +175,38 @@ it('ignores a terminal stream error when plugin disposal already owns teardown', await disposal expect(actions.hooks.account.getSnapshot().failed).toBe(false) }, 60_000) + +it('reads the account task impact and reports a refused query', async ({ start, mock }) => { + vi.stubGlobal('dshDesktop', {}) + const c = await start() + const actions = operations(c) + mock.remote.account.hasRunningAccountTasks.mockResolvedValueOnce(ok(true)) + expect(await actions.hasRunningAccountTasks()).toBe(true) + 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/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-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-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/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登录 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 @@ +设置联系我们登录 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/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/initialization.client.spec.ts b/packages/client/ui-settings-account/tests/initialization.client.spec.ts new file mode 100644 index 0000000000..4f3c287799 --- /dev/null +++ b/packages/client/ui-settings-account/tests/initialization.client.spec.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +/** Account state updates continue after default-model initialization refuses. */ +import { expect, it, vi, afterEach } from 'vitest' +import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' +import { apply, type AccountSectionInjected } from '../src/client/index.ts' + +afterEach(() => vi.unstubAllGlobals()) + +it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { + vi.stubGlobal('dshDesktop', {}) + 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' || outcome === 'pending', 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: () => { + 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 + }, + }), + }, + } + try { + apply(ctx as never) + await done.promise + expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith() + 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/onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx index 7cc8eb0067..5bec17cde5 100644 --- a/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx @@ -25,6 +25,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte } const props = { ...globals, + hasRunningAccountTasks: vi.fn(async () => false), complete: vi.fn(), useApiKey: vi.fn(), setOnboarding: vi.fn(), showLogin: vi.fn(), useAccount: (select: (snapshot: AccountSnapshot) => T) => select(account), useTheme: (select: (snapshot: ThemeSnapshot) => T) => select(theme), 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..d2fbb29028 --- /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 | '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, '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 === '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() + 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/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 29a2070e75..1aad6500b2 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: 52a8e91c158db912a5c7a4f84ff2892de2bd4a7d -README.zh.md: 9470c9c0f1e80136e31e6e656b09c5fd17f7c691 +README.md: 809d7407edbee9cbc00a19ca864d2e1424f29866 +README.zh.md: 9d5154cde596c03b51d297c0b49a01cac16db054 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 52a8e91c15..809d7407ed 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -25,7 +25,11 @@ English | [中文](README.zh.md) ## Use this package -Open the Models page from the Settings navigation to see every configured provider as a row. The official DeepSeek provider (`deepseek-official`) always appears first; other providers retain their directory order. 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. +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. + +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. @@ -55,6 +59,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. 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 9470c9c0f1..9d5154cde5 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -25,7 +25,11 @@ kind: "package-reference" ## 使用本包 -从设置导航打开 Models 页面,即可看到每个已配置的提供商都有一行。官方 DeepSeek 提供商(`deepseek-official`)始终排在第一位,其他提供商保持目录中的原有顺序。其配置键未在任何位置配置的整分节提供商会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 +保存凭据或自定义提供方时保留已选模型。用户可在 composer 中选择可用模型。 + +提供方列表中 DeepSeek 账号排第一,DeepSeek 排第二;第三方提供方保留目录中的相对顺序。 + +从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 存在已存储目录错误的提供商仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 @@ -55,6 +59,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 输入框。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。 + ----- diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index d5743645b2..dc43ad98d0 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -1,8 +1,8 @@ /** * Models settings section: the provider rows joined from the configurable * directory, settings namespaces, and credential states, with one editor - * card at a time. The official DeepSeek provider appears first; other rows - * retain directory order. Rows expose only confirmed API-key state through accessible + * card at a time. Rows retain the account-first order supplied by the store + * and expose only confirmed API-key state through accessible * solid configured or missing dots. A whole-section provider without a * configured key renders as its open setup card instead of a row, but only in * the first-run posture — no provider on the page can serve requests yet — and @@ -167,7 +167,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 } @@ -231,7 +231,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 [addOpen, setAddOpen] = useState(false) const [addMode, setAddMode] = useState('catalog') @@ -336,9 +338,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS // One fact decides both first-run postures on this page and the onboarding // step: whether the user already has a provider to talk to. const anyUsable = state.rows.some(providerUsable) - const configured = state.rows.filter(row => row.configured).sort((left, right) => ( - Number(right.entry.provider === 'deepseek-official') - Number(left.entry.provider === 'deepseek-official') - )) + const configured = state.rows.filter(row => row.configured) const configurable = state.rows.filter(row => state.namespaces.has(row.entry.settingsNs)) const addable: AddableRow[] = state.rows.flatMap((row) => { const namespace = state.namespaces.get(row.entry.settingsNs) diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index b4df6a7124..3ed7b42d87 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -183,6 +183,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`. @@ -194,6 +195,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 @@ -203,7 +205,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]) @@ -368,6 +370,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 df55037650..ff6870b572 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', 'configForms', 'settingsSchema', ] @@ -122,6 +122,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 9c8a44d9e0..bba0b85288 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', @@ -122,6 +123,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/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 2af1d54d5c..34a20107e3 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,11 +64,15 @@ 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. */ 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 +212,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) { @@ -229,7 +240,8 @@ 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)] return { @@ -264,6 +276,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 3c6b1b903f..62196478f7 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -47,6 +47,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 } @@ -109,7 +110,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', 'configForms', '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 3335ddaa2d..1a2398bd2a 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' +import { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { CredentialInfo, RemoteResult, SettingsNamespaceView, @@ -12,7 +13,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 +23,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) @@ -219,7 +220,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 = Object.assign(new Context(), { remote: { ...face, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + } }) contexts.set(face, ctx) return ctx } @@ -1111,6 +1114,16 @@ describe('ModelsSection', () => { expect(baseURL.value).toBe('') }) + it('saves credentials without changing the default model', async () => { + const { ctx, set } = await mountDeepSeekCard() + const initialize = vi.spyOn(ctx.remote.session, 'initializeDefaultModel') + 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 () => { const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) @@ -1869,3 +1882,34 @@ 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`) +}) + +it('renders the localized account row and supports catalogs without capacity defaults', async () => { + const scripted = scriptedFace({}) + const { controller, view } = await mountFace(scripted) + const row = controller.store.getSnapshot().rows[0]! + await act(async () => { controller.store.update((state) => { + state.rows = [{ ...row, accountAvailable: true, entry: { ...row.entry, provider: 'deepseek-account' } }] + }) }) + expect(screen.getByText(en.deepSeekAccount)).toBeTruthy() + view.unmount() + const namespace = wireNamespaces()[0]! + render( {}} />) + expect(screen.queryByLabelText(en.keyInput)).toBeNull() +}) 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/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 9eec2d8dcd..f04b8853b4 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 16af464c77..eb0e920fbc 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 @@ -3,6 +3,7 @@ import { within, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' +import { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -153,7 +154,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 = Object.assign(new Context(), { remote: { ...face, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + } }) contexts.set(face, ctx) return ctx } @@ -858,6 +861,21 @@ describe('hand-declared providers', () => { return { ...scripted, onClose } } + 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') + 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 () => { 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 3e3e3dd5dc..79905b4441 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' }] : [], [{ @@ -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 } } @@ -64,6 +74,7 @@ const NAMESPACES = [ ] function api(overrides: { + accountAvailable?: boolean providers?: () => Promise> describeSettings?: () => Promise> describeCredentials?: (refs: readonly string[]) => Promise>> @@ -88,6 +99,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 +342,36 @@ 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 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/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 6776e24407..f663aee45e 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/core/agent-loop/package.json b/packages/core/agent-loop/package.json index f7f48f88d2..8851ac42c4 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/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/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 0474a63be1..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: 94816e5819027b0d68d39379a9905dfe02768029 -README.zh.md: 9e1df8a19881960e4d4b4423f9cfa89c463d237d +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 94816e5819..8db7824fee 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`. 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 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 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. @@ -28,6 +30,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 9e1df8a198..b4525f135f 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`。此时所有 profile 的 Host 授权、资料、余额和退登请求都携带 `x-client-platform: web`;Desktop profile 提供 `darwin` 或 `win32`,改为携带 `x-client-platform: desktop-mac` 或 `desktop-win`。该请求头由 provider 拥有,部署配置无法覆盖。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头。 +资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发送实时 `deepseek-account/session-expired` 通知。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。Host 推理调用方可以通过 `rejectToken` 报告被拒绝的请求 token;仅当它仍匹配当前登录凭据时才执行清除。 + ## 概述 通过系统浏览器登录,并将账号凭证保存在现有本地凭证存储中。本地取消会阻止迟到的回调和兑换响应使用户登录。 @@ -26,6 +28,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/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index d77606bd7b..a3638116b4 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 or response code 40003. */ 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 6eb4071350..811e9ef3d0 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' @@ -102,6 +102,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) @@ -169,8 +170,11 @@ 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 { - 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 }, } } @@ -194,12 +198,44 @@ export class PlatformAccount extends DeepSeekAccount { delete this.attempt.initialProfile return initial as AccountDetails[K] } - const details = await readAccountDetail(field, this.origin, stored.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, stored.token, + AbortSignal.any([lifetime.signal, AbortSignal.timeout(this.requestTimeout)]), + { ...this.accountRequestHeaders, ...this.clientHeaders }) + return this.detailsLifetime !== lifetime ? null : details + } catch (_unauthorized) { + // readAccountDetail exposes only authenticated credential rejection failures. + if (this.detailsLifetime === lifetime) await this.expireCredential(stored.token, lifetime) + return null + } } + + 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) + 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() + 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() + return this.getState() + })().finally(() => { this.removing = undefined }) + await this.removing + } + + override async getPlatformSession(): Promise { const lifetime = this.detailsLifetime const stored = await this.readCurrentGrant(lifetime) @@ -223,6 +259,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) @@ -312,6 +349,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/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index c437f67ac3..c8e65107d8 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 or code 40003. */ +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') @@ -153,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, @@ -173,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 aa5761b9c5..0b8d0d27ce 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -49,6 +49,8 @@ async function fixture( let origin = '' let detailsHold = false let balanceHold = false + let detailCode = 0 + let detailStatus = 200 let profileFailed = false let summaryFailed = false let logoutFailed = false @@ -79,8 +81,11 @@ 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 } + 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 } } @@ -156,6 +161,8 @@ 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 }, holdDetails: () => { detailsHold = true }, failSummary: () => { summaryFailed = true }, @@ -393,6 +400,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) @@ -862,6 +881,78 @@ it.each([undefined, [{ currency: 'EUR', balance: '1' }], [{ currency: 'CNY', bal }, ) +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++ }) + 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', 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) => { + const f = await fixture() + 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', 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 () => { + const f = await fixture() + 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(expired).toHaveBeenCalledOnce() + expect(await f.account.getState()).toMatchObject({ status: 'signed-out' }) +}) + +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' } }) +}) + it.each([{ kind: 'api-key' as const, key: 'wrong-kind' }, { kind: 'grant' as const, payload: { version: 0 } }])( 'rejects invalid stored account records across account consumers: $kind', async (record) => { const f = await fixture() @@ -1199,3 +1290,139 @@ it('invalidates account reads before the shared grant lookup returns to its call expect(f.detailRequests).toEqual([]) } finally { read.mockRestore() } }) + +it('cancels a pending sign-in when the current stored grant expires', async () => { + const f = await fixture() + await storeAccount(f) + await f.account.startSignIn('en', f.callbackOrigin, 'desktop') + 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 }) +}) + +it('retains the grant when local expiry removal fails and permits a later retry', async () => { + const f = await fixture() + await storeAccount(f) + f.detailStatus(401) + const remove = vi.spyOn(f.ctx.credentials, 'deleteRecord').mockRejectedValueOnce(new Error('storage failed')) + try { + await expect(f.account.getBalance()).rejects.toThrow('storage failed') + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) + expect(await f.account.getState()).not.toHaveProperty('signOutReason') + expect(await f.account.getBalance()).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'signed-out' }) + } finally { remove.mockRestore() } +}) + +it.each(['invalidated', 'missing', 'other-kind', 'token', 'issuer'] as const)( + 'rechecks the stored grant before expiry removal: %s', async (change) => { + const f = await fixture() + await storeAccount(f) + f.detailStatus(401) + const key = credentialKey('deepseek-account-platform', 'default') + const original = f.ctx.credentials.readRecord.bind(f.ctx.credentials) + let reads = 0 + const read = vi.spyOn(f.ctx.credentials, 'readRecord').mockImplementation(async (requested) => { + const record = await original(requested) + if (++reads !== 2) return record + if (change === 'invalidated') f.ctx.emit('credentials/record-updated', key) + if (change === 'missing') return undefined + if (change === 'other-kind') return { kind: 'api-key', key: 'another-kind' } + if (change === 'token' || change === 'issuer') return { + kind: 'grant', payload: { version: 1, issuer: change === 'issuer' ? 'https://another.example' : f.origin, token: change === 'issuer' ? 'test-platform-grant' : 'replacement-token' }, + } + return record + }) + const remove = vi.spyOn(f.ctx.credentials, 'deleteRecord') + try { + expect(await f.account.getBalance()).toBeNull() + expect(remove).not.toHaveBeenCalled() + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) + } finally { read.mockRestore(); remove.mockRestore() } + }, +) + +it('ignores a 401 whose response cleanup overlaps a credential replacement', async () => { + const f = await fixture() + await storeAccount(f) + const response = new Response('', { status: 401 }) + const cancel = response.body!.cancel.bind(response.body) + const cleanup = vi.spyOn(response.body!, 'cancel').mockImplementation(async () => { + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer: f.origin, token: 'new-login' }, + })) + await cancel() + }) + const fetchResponse = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(response) + try { + expect(await f.account.getBalance()).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: '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') }) + 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, published, detailRequests: f.detailRequests }) + .toMatchInlineSnapshot(` + { + "detailRequests": [], + "published": [ + "session-expired", + "signed-out", + ], + "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()).not.toHaveProperty('signOutReason') +}) + + +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() + } +}) 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() } +}) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 08b3bb0cbf..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: c54dc4feced92308a83c47aa0eb7033766708205 -README.zh.md: 1df0ca4cdc7d55584d8dcba29706746c87dcb31f +README.md: f82cdcfec18d913cec1dba5ca0a2a1a0f0a1404c +README.zh.md: 94efac994da792e3bdb4a819a860c4bb9a2d0419 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index c54dc4fece..f82cdcfec1 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -11,6 +11,10 @@ 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. + +`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 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. @@ -26,8 +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`. 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 1df0ca4cdc..94efac994d 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -11,6 +11,10 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token `desktopClientHeaders` 将原生平台 `darwin` 和 `win32` 映射为 Desktop 账号与更新策略请求共用的请求头;`null` 不添加请求头。 +`rejectToken` 接收 Host 推理请求被拒绝的 token,仅删除与它匹配的当前登录凭据;延迟返回的拒绝不能清除替换后的凭据。 + +`deepseek-account/session-expired` 在移除被拒绝的凭据后,向当前订阅方通知一次。账号快照不携带失效提示,因此重新连接不会重复弹出 toast。 + ## 概述 账号使用方可读取本地登录状态、发起或取消浏览器登录,并在保留 API Key 的情况下退出。Host 模型使用方仅能为提供者配置的推理来源解析账号凭证。 @@ -24,10 +28,14 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token ## 使用此包 +本地成功退出登录后发出 `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 eb19e820fd..f79daa71eb 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 9e91d591c7..9a40ecf712 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -1,9 +1,16 @@ /** 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' { + interface Events { + /** Local grant removal has completed. + * @mode emit + */ + 'deepseek-account/signed-out'(): void + } interface Context { deepseekAccount: DeepSeekAccount } @@ -53,7 +60,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 @@ -69,6 +76,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 the expiry notification is emitted. + */ + 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/src/types.ts b/packages/credentials/deepseek-account/src/types.ts index 7245ba1418..a7cbe846ab 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. */ @@ -45,3 +46,16 @@ 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 { + /** 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 + */ + '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/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index 257020b4df..2a94ae80d9 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/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6f8e8962d2..c68c5d8a8f 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -879,7 +879,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.', }, @@ -895,6 +895,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 the expiry notification is emitted.', + }, { signature: 'abstract getPlatformSession(): Promise', description: 'Read credentials for the configured Platform origin, bound to their issuing environment.', @@ -1405,7 +1411,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.', }, @@ -1831,6 +1837,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(): 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.', + }, { signature: '@Remote(\'modelCatalog\') modelCatalog(): Promise', description: 'Describe every currently routable model for Host-generation selectors.', @@ -3877,6 +3889,30 @@ 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/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', + 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/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index a028aba2e7..3c4d506ae4 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/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..c55756b190 --- /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: 13b6df830e7ef16a0b75ff46678c507822f543b7 +README.zh.md: f2ca9fe281a9a10f1e75b9a941ad67da21d86b63 diff --git a/packages/llm/llm-deepseek-account/README.md b/packages/llm/llm-deepseek-account/README.md new file mode 100644 index 0000000000..13b6df830e --- /dev/null +++ b/packages/llm/llm-deepseek-account/README.md @@ -0,0 +1,77 @@ +--- +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. + +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) +- [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..f2ca9fe281 --- /dev/null +++ b/packages/llm/llm-deepseek-account/README.zh.md @@ -0,0 +1,77 @@ +--- +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 请求实现;本包拥有凭据解析和目录可用性判断。 + +鉴权解析返回 `x-dsh-auth-token` 和捕获同一 token 的失败回调。HTTP 401 的分类和凭据失效处理由此提供方负责;被拒绝的旧请求不能清除替换后的登录。 + +## 目录 + +- [使用此包](#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..96166d4c33 --- /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-rc.1", + "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..847a792cd2 --- /dev/null +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -0,0 +1,50 @@ +/** 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 { DeepSeekRequestAuth, 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 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 { + 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, resolveAuth, providerName: 'DeepSeek Account', + discoverModels: async (provider) => { + const connection = options() + try { await resolveAuth(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..4a24a98eb7 --- /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: 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 new file mode 100644 index 0000000000..4b22ef2ae9 --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/README.md @@ -0,0 +1,78 @@ +--- +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. + +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) +- [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. 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 + 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 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..df6baea123 --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/README.zh.md @@ -0,0 +1,78 @@ +--- +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 请求实现;本包拥有凭据解析和目录可用性判断。 + +鉴权解析将验证后的 API key 放入 `x-api-key`,供 Messages 和 Files 请求共同使用。 + +## 目录 + +- [使用此包](#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..c932986179 --- /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-rc.1", + "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..cf039f9a0a --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/src/index.ts @@ -0,0 +1,47 @@ +/** 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, providerName: 'DeepSeek', + resolveAuth: async connection => ({ headers: { 'x-api-key': await resolveApiKey(connection) } }), + discoverModels: (provider) => { + const connection = options() + return Promise.resolve(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..bac1caa441 --- /dev/null +++ b/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts @@ -0,0 +1,20 @@ +/** 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.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, {}) + 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-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 f3872a2482..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: 1fad3d3f832ff21c6ae1616d3637674d6c923b2b -README.zh.md: 9914f53f732d0586fa14e2f1c6b9b05ed47c6567 +README.md: f877eb53db82e76b23509a20f39de66d72bc9386 +README.zh.md: cd2da99286705913c07bed9e9ded8a9c198e3d6b diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 1fad3d3f83..f877eb53db 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -9,7 +9,9 @@ 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). + +`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 @@ -25,7 +27,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 +38,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 +49,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 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 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 +72,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. @@ -84,9 +85,9 @@ Messages sends text, thinking, tool calls, and tool results as content blocks, r ### Account credentials -When the [account provider](../../credentials/deepseek-account-platform/README.md) returns a stored token for the resolved endpoint, that token takes priority over the configured API key. Eligibility follows the provider's `inferenceOrigin`, which defaults to `https://api.deepseek.com`. Other origins and signed-out accounts use the configured API-key reference. Signing out removes the account grant and preserves API keys. +`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. The account provider owns HTTP 401 classification and credential invalidation; the transport passes failures to its callback. ### Streaming with thinking and images @@ -114,7 +115,9 @@ Configuration accepts Messages only and has no `protocol` field. If resolution r Successful Files responses must contain valid JSON. JSON decoding failures from upload, list, retrieve, and delete throw `INVALID_RESPONSE` with the operation and HTTP status in the message, the status in `LlmError.failure`, and the original parser error as `cause`. Body-read transport and cancellation errors retain their identity. -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 neither an eligible account token nor 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. +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 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. ----- @@ -132,7 +135,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 @@ -212,3 +215,5 @@ These limits define where the adapter stops and future work begins. They are cur 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 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 9914f53f73..cd2da99286 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -9,7 +9,9 @@ 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)并用。 + +`resolveAuth(connection)` 返回提供方持有的鉴权请求头,以及绑定本次请求凭据的可选失败回调。Messages 和 Files 直接使用这些请求头,不判断凭据类型。上传复用按端点和鉴权请求头的哈希隔离,不持久化原始凭据。 ## 目录 @@ -25,7 +27,7 @@ kind: "package-reference" ## 使用本包 -将此插件与 harness LLM 服务一起挂载以提供 `deepseek-official`。它在每次操作开始时从 Config 引用捕获连接选项。 +本包导出传输库;provider 插件向 harness LLM 服务注册路由。它在每次操作开始时从 Config 引用捕获连接选项。 适配器接受 LLM 服务的[仅供请求使用的 user 输入](../llm/README.zh.md#use-this-package),并可将其与持久历史混用;省略请求输入的身份与来源不会改变提供方内容。 @@ -36,7 +38,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 +49,10 @@ 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` 失败。 | 字段 | 默认值 | 含义 | |---|---|---| -| `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 +72,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` 可以提早压缩。 @@ -84,9 +85,9 @@ Messages 以内容块发送文本、思考、工具调用和工具结果,以 ` ### 账号凭据 -当[账号提供者](../../credentials/deepseek-account-platform/README.zh.md)为已解析端点返回保存的 token 时,该 token 优先于配置的 API Key。适用范围由提供者的 `inferenceOrigin` 决定,默认为 `https://api.deepseek.com`。其他源以及已退出登录的账号使用配置的 API Key 引用。退出登录会删除账号授权,保留 API Key。 +`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`。两种凭据模式均拒绝重定向。账号 provider 负责 HTTP 401 分类和凭据失效处理;传输层将错误交给其回调。 ### 带 thinking 与图片的流式调用 @@ -114,7 +115,9 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 成功的 Files 响应必须包含有效 JSON。上传、列举、获取和删除操作的 JSON 解码失败抛出 `INVALID_RESPONSE`,消息包含操作名称与 HTTP 状态,`LlmError.failure` 保留该状态,`cause` 保留原始解析错误。读取响应体时的传输和取消错误保留其原有身份。 -非 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`,默认重试策略会重试它。既没有适用账号 token 也没有 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 +非 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` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 + +提供方插件负责目录可用性;仅账号路由要求存有凭据才能发现模型。两者的目录独立配置;传输层提供共享的默认模型元数据和能力解析。 ----- @@ -132,7 +135,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) 管理上传复用与恢复。 ### 协议流程 @@ -212,3 +215,5 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 无。 **运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 + +`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 2971f1a479..909fbd5adb 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -31,15 +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/cordis-plugin-loader": "workspace:~" }, "dependencies": { "@deepseek-ai/cosmokit": "workspace:~", @@ -49,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:*", @@ -79,9 +79,6 @@ "peerDependenciesMeta": { "@deepseek-ai/cordis-plugin-loader": { "optional": true - }, - "@deepseek-ai/dsh-deepseek-account": { - "optional": true } } } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 4490dd9b6e..67f9890047 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,23 +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: 'DeepSeek' } } + override providerInfo(provider: string) { return { id: provider, name: this.dependencies.providerName ?? 'DeepSeek' } } override providerRetryPolicy(_provider: string) { return this.dependencies.options().retryPolicy } - override listModels(provider: string) { - const connection = this.dependencies.options() - return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model))) + override async listModels(provider: string) { + return this.dependencies.discoverModels?.(provider) ?? [] } override resolveModel(provider: string, model: string, _signal?: AbortSignal) { return Promise.resolve(modelInfo(this.dependencies.options(), provider, model)) @@ -49,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') @@ -74,71 +73,80 @@ 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( options.messages, connection, options.model, this.dependencies.resolveAttachments?.(), this.imageAccess, signal, ) - const accountToken = await this.dependencies.resolveAccountToken?.(connection) - const key = accountToken ?? await this.dependencies.resolveApiKey(connection) - 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 auth = await this.dependencies.resolveAuth(connection) + try { + const files = new RequestFiles(this.files, { + baseURL: connection.baseURL, headers: auth.headers, + }, + 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 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', + ...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(), + ...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) }) } - 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 } - 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 (auth.onRequestError !== undefined) { + let mapped: unknown + try { mapped = await auth.onRequestError(error) } + 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/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 new file mode 100644 index 0000000000..a98fecbec5 --- /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' | '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({ + ...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 473f2dfde5..623dbea335 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,18 +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/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, @@ -28,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, @@ -50,87 +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 = new DeepSeekAdapter({ - 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), - 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: [] }, - ]) - // 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) - 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]) - 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 cc623b0a50..60c6411696 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. */ @@ -82,21 +74,26 @@ 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 { +export interface DeepSeekAdapterOptions { /** Report unusable native Messages replay metadata without exposing content or signatures. */ onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void + /** 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 /** Current validated connection facts; called once per operation. */ - options: () => DeepSeekConnectionOptions - /** - * Resolve the API key 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. - */ - resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise - /** Resolve a DSH account token only for an eligible official endpoint. */ - resolveAccountToken?: (connection: DeepSeekConnectionOptions) => Promise + options: () => Connection + /** 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/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts new file mode 100644 index 0000000000..74c0996d50 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -0,0 +1,289 @@ +/** Account route cancellation uses the real loop, registry, and tool lifecycle. */ +import { afterEach, expect, it, vi } from 'vitest' +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' +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 '@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[] = [] +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) + 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 +} + +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('never falls back to an API key while signed out', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-api-key') + const ctx = new Context() + 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' } }) + 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.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.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.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' } } }) +}) + +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.session.requestContext()?.provider).toBeUndefined() + send(agent) + await preparing.promise + expect(agent.session.requestContext()?.provider).toBe('deepseek-account') + proceed.resolve(undefined) + const signal = await streaming.promise + 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.status).toBe('idle') +}) + +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.session.requestContext()?.provider).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('never borrows an account token for a missing API key', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + const resolveToken = vi.fn(async (_url: string) => 'fixture-account-token') + 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' } }) + 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 (_url: string) => 'fixture-account-token' } 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() +}) + +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')).not.toHaveLength(0) + token = 'fixture-account-token' + expect(await ctx.llm.listModels('deepseek-account')).not.toHaveLength(0) + 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 + expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) +}) + +it('surfaces credential storage failures during catalog discovery', async () => { + const ctx = await harness() + 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..4bf5cd2050 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, headers: { 'x-api-key': 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 123758542f..919800f696 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', }) @@ -166,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, }) @@ -183,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') @@ -240,6 +239,19 @@ 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({}), + 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, + }) + 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 +294,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], @@ -302,6 +314,82 @@ describe('Cordis provider composition', () => { return { ctx, http } } + 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(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: provider === 'deepseek-account' ? 'ACCOUNT_TOKEN_INVALID' : 'AUTH' } }, + }) + 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) + 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) + installAccountTaskCancellation(ctx) + 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 HTTP 403', async () => { + const { ctx } = await boot((response) => { + response.writeHead(403) + 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 }, @@ -345,7 +433,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 }], @@ -431,7 +519,7 @@ describe('Cordis provider composition', () => { it('loads one provider from YAML, rotates settings and credentials, then removes disposed registrations', async () => { const { ctx, http } = await boot() - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek-official']) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek-official', 'deepseek-account']) expect((await assemble(ctx.llm.stream(options()))).assembler.finish.kind).toBe('stop') expect(http.requests[0]?.headers['x-api-key']).toBe('stored-key') const second = await endpoint() @@ -488,7 +576,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) @@ -498,6 +586,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 1a2cb322d6..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' @@ -192,6 +192,7 @@ describe('request-level dynamic configuration', () => { }) it('advertises a live settings catalog without re-registration', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const dir = await home() const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) @@ -274,6 +275,7 @@ describe('request-level dynamic configuration', () => { }) it('fails catalog reads while a stored snapshot fails beyond-schema validation, and recovers on repair', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const dir = await home() const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) 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/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/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/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/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..a54647ef7e 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)[] = [] @@ -17,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 7e9c0e2832..4738fe9b6a 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, headers: { 'x-api-key': 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 4d657ec263..4c505e1fe2 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,12 +73,12 @@ 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({ 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 }, @@ -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 { @@ -191,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 @@ -214,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, }) @@ -229,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({ @@ -255,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 @@ -320,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, }) @@ -337,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 }, @@ -1084,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, @@ -1103,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) }, @@ -1111,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, }) @@ -1130,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) }) @@ -1702,7 +1703,7 @@ describe('plugin registration and config', () => { expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', displayName: 'DeepSeek', - settingsNs: 'llm-deepseek', + settingsNs: 'llm-deepseek-api-key', settingsPath: [], }]) await fiber.dispose() @@ -1730,6 +1731,7 @@ 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, { baseURL: 'http://127.0.0.1:1' }) @@ -1858,6 +1860,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, LlmDeepSeek.Config({ baseURL: 'http://127.0.0.1:1' })) @@ -1873,23 +1876,9 @@ 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') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { @@ -2175,13 +2164,11 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) }) - it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { + it('loads keyless with an empty catalog and fails requests actionably', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { 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' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) @@ -2269,22 +2256,21 @@ describe('plugin registration and config', () => { 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 () => { 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/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 4d5b712122..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,9 +55,6 @@ }, { "path": "../../settings/settings" - }, - { - "path": "../../credentials/deepseek-account" } ] } diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index bd1d5484b9..ec5f679e25 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-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 1081125a7e..58b8d6afec 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1034,3 +1034,16 @@ describe('abort wiring', () => { expect(server.requests).toHaveLength(1) }) }) + +it.each([ + new LlmError('missing credential', 'MISSING_CREDENTIAL'), + new LlmError('invalid credential', 'INVALID_CREDENTIAL'), + new Error('credential storage failed'), +])('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(), + }) + expect(await adapter.listModels('deepseek')).not.toHaveLength(0) +}) 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 538df72610..92aa2d9f3e 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 dbc3bd15e7..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: 04de24592379dcb0d815f3b033ca2f9f22e2ff6a -README.zh.md: bee35d4a0b62dd61a61b435dfc4eb27b62cdce69 +README.md: c6697f5570c1af2077585d586e38f024a7b14f16 +README.zh.md: c288c924ffbb15d590f4f2b25c97409ccf80a4bd diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 04de245923..c6697f5570 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 @@ -37,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 bee35d4a0b..c288c924ff 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)摘要器——都会通过本服务流式发起请求。与至少一个提供方适配器一起挂载它;服务本身没有任何配置,也不包含提供方协议代码。 ### 何时选择 @@ -37,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/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. */ 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/sdk/server/package.json b/packages/sdk/server/package.json index 5126fe044d..94ade41756 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 3448c5dc87..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' @@ -922,7 +922,9 @@ 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' }, + ]) await server.shutdown() } finally { await ctx.fiber.dispose() 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 967cc0a766..94f63fafde 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 710662e238..c5c6ddc0ee 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/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 0cbe5e956b..f6e0eb9403 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 f5288d0da0..9dc51966a2 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/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() }), }) diff --git a/packages/workflow/workflow-ptc/package.json b/packages/workflow/workflow-ptc/package.json index 9409a0757c..3021ca8e28 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 9d834a613b..eafff40d37 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 @@ -1003,6 +1009,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 @@ -1165,6 +1174,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 +1283,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 +1328,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 @@ -1923,9 +1941,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 @@ -2217,9 +2238,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 @@ -6158,6 +6179,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 @@ -6464,6 +6488,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 @@ -6683,9 +6710,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: @@ -6967,6 +7000,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 @@ -8126,6 +8162,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 @@ -9144,6 +9183,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 @@ -9169,6 +9214,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': @@ -9214,6 +9308,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 @@ -9260,6 +9357,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 @@ -10005,9 +10105,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 @@ -10400,6 +10500,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 @@ -10718,6 +10821,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 @@ -11977,6 +12083,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 @@ -12151,6 +12260,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 @@ -13389,6 +13501,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 @@ -13630,6 +13745,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 @@ -18677,9 +18798,13 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -26091,6 +26216,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@7.2.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -27824,7 +27958,7 @@ snapshots: rimraf@2.6.3: dependencies: - glob: 7.2.3 + glob: 7.2.0 roarr@2.15.4: dependencies: 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/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 644f7073b2..631632678f 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -224,6 +224,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/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 976641c05e..d3732326c6 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -662,11 +662,7 @@ "5c8c41ba658c0576bea6c34b1226c27e7b0328c03c9eea0d6c920f940c9d8f5b": 1 }, "packages/client/ui-settings-models/tests/components.client.spec.tsx": { - "149134633dbc576c06d4fd7ae295957de037e229a7d9f029d5576be5f3d56bd0": 1, - "34e91417f24ec20ab9adb34c131dafc76c9481af9163a6a32e0c4eda834be8a5": 1 - }, - "packages/client/ui-settings-models/tests/provider-form.client.spec.tsx": { - "34e91417f24ec20ab9adb34c131dafc76c9481af9163a6a32e0c4eda834be8a5": 1 + "149134633dbc576c06d4fd7ae295957de037e229a7d9f029d5576be5f3d56bd0": 1 }, "packages/client/ui-settings-models/tests/store.client.spec.ts": { "6b8b0a920c558092d20bc17623b7050e0f7403f896fde7da17bb45677899b910": 1 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 new file mode 100644 index 0000000000..7da825a7be --- /dev/null +++ b/snapshots/sdk/account-provider-signout/account-signout.mjs @@ -0,0 +1,11 @@ +/** 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) { + 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..647a6529ea --- /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-account + name: '@deepseek-ai/dsh-llm-deepseek-account' + 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..1c7772f4a0 --- /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":"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":"runtime-context","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/session.v4.jsonl b/snapshots/sdk/account-provider-signout/session.v4.jsonl new file mode 100644 index 0000000000..aca42b16d7 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/session.v4.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":4,"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":"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":"runtime-context","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 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 3ea0714ee5..4c1d7b165f 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 2d31d0df31..ff0b066bba 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -344,6 +344,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" },