fix(session-controller,ui-model-selection,ui-settings): handle catalog and account setup failures

This commit is contained in:
lsdsjy committed 2026-09-21 14:55:08 +08:00
1 parent 9cf12959f2
commit 0d2fbaad67
63 files changed
+348 -123

No files matched your search

+2 -4
View File
@@ -132,10 +132,8 @@ describe('web e2e: the composer model switch is the default for later sessions',
const box = page.locator('[data-composer-input]').first()
await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
// What removing the provider on the Models page leaves behind: the saved
// default still names the route, and nothing serves it any more.
// `replace`, not `update`: a merge patch of `{providers: {}}` leaves every
// stored profile in place.
// Removing the selected provider preserves the other route and the saved
// selection. A merge update would retain the removed profile.
await scaffold.ctx.settings.replace('llm-pi-ai', { providers: {
[START_ROUTE]: { displayName: 'Origin Gateway', api: 'openai-completions',
baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] },
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
architecture.md: 3e651fa4433f0d6bab0ba73cd85929ed45e9ee79
architecture.zh.md: a30bb804c419f38e3f68a06c252e90e4993b9e42
architecture.md: fd3e04f7287ce97628b162becf647284221c8f64
architecture.zh.md: f5e3bf8a3a6746044d5fbd229997eeb21303576c
+1 -1
View File
@@ -112,7 +112,7 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo
`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
Immutable requests retain cancellation. `Agent.activeProvider` exposes current provider ownership. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules.
The loop sends immutable requests with live cancellation and reuses message-freeze evidence only for identities it fully froze; [agent-loop](../packages/core/agent-loop/README.md) owns request construction. `Agent.activeProvider` identifies the provider of the latest prepared request.
Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
+1 -1
View File
@@ -116,7 +116,7 @@ turn/end
`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 `Agent.activeProvider` 暴露当前提供方归属。
循环发送不可变请求并保留实时取消能力,只对已完整冻结的消息对象身份复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。`Agent.activeProvider` 标识最近一次已准备请求的提供方。
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: c5535c610ed88f265efe5065e2ff3374030192d4
event-producer-consumer.zh.md: b2e558f5e711a3cf3628f1b9da802c1d69693090
event-producer-consumer.md: f8160835916b5f1e6e71583c2adf647e4d996316
event-producer-consumer.zh.md: 7a17b855917b7cb35d0fe67a56a9040dcd615978
+5 -5
View File
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+5 -5
View File
@@ -23,11 +23,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1
persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b
persistence-catalog.md: f71cb89c5494c77df8d036faddea811a110444e1
persistence-catalog.zh.md: abd0280d094b9f24fea7d9abe59d2ad17af263c3
+2 -2
View File
@@ -5304,7 +5304,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7195,7 +7195,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
Sources: [`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+2 -2
View File
@@ -5306,7 +5306,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7197,7 +7197,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
来源:[`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+2 -2
View File
@@ -44144,7 +44144,7 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
"packages/api/session-controller/src/types.ts:380",
"packages/api/session-controller/src/types.ts:381",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:20",
@@ -57727,7 +57727,7 @@
},
"names": [],
"sources": [
"packages/api/session-controller/src/types.ts:385"
"packages/api/session-controller/src/types.ts:386"
]
},
{
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
llm-streaming.md: 07fc6599bc5d4eb5a4af9b566032004932052585
llm-streaming.zh.md: 079a33599d1e5169af6402db2b2abdd7dc978369
llm-streaming.md: 6ff93698cb877591fb85bc1a05d1dabc95b6561b
llm-streaming.zh.md: 400fcd5a96de0097ec827055fe6f96fc3eef0846
+5 -3
View File
@@ -837,8 +837,9 @@ declare abstract class LlmAdapter {
imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;
/**
* List models this adapter can currently advertise for one owned provider.
* The result is advisory: an adapter may accept unlisted model ids, and
* consumers must not turn absence into request rejection.
* Core routing accepts unlisted model ids; catalog-driven entry points such
* as the GUI may require membership. Adapters used there must advertise
* their available models; the base empty catalog offers no GUI selection.
* @param _provider - one provider route owned by this adapter.
* @returns discoverable models in adapter-preferred order.
*/
@@ -1014,7 +1015,8 @@ fileRequestText(ref: FileAttachmentRef): string
/**
* Discover models advertised by one registered provider. Catalog membership
* is advisory and never changes routing or request validation.
* does not constrain core routing. Catalog-driven entry points may restrict
* selection and submission to the advertised models.
* @param provider - registered provider route to inspect.
* @returns detached model metadata in adapter-preferred order.
*/
+5 -3
View File
@@ -843,8 +843,9 @@ declare abstract class LlmAdapter {
imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;
/**
* List models this adapter can currently advertise for one owned provider.
* The result is advisory: an adapter may accept unlisted model ids, and
* consumers must not turn absence into request rejection.
* Core routing accepts unlisted model ids; catalog-driven entry points such
* as the GUI may require membership. Adapters used there must advertise
* their available models; the base empty catalog offers no GUI selection.
* @param _provider - one provider route owned by this adapter.
* @returns discoverable models in adapter-preferred order.
*/
@@ -1020,7 +1021,8 @@ fileRequestText(ref: FileAttachmentRef): string
/**
* Discover models advertised by one registered provider. Catalog membership
* is advisory and never changes routing or request validation.
* does not constrain core routing. Catalog-driven entry points may restrict
* selection and submission to the advertised models.
* @param provider - registered provider route to inspect.
* @returns detached model metadata in adapter-preferred order.
*/
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
README.md: 5f09de61395c3d729144203b77eeb299c217128e
README.zh.md: dfb5b97b5804b4512d17eab47866ee079fbc047d
README.md: 5b96af2040c95e6e9195d55207ead0e0acbdba12
README.zh.md: 42961830f1731ad9ce3efe048ebde03490f80637
+1 -1
View File
@@ -68,7 +68,7 @@ References keep local Session data, scoped Contexts, and history streams alive,
`SessionMediaReferences` mounts `GET|HEAD /api/file?path=<absolute path>` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work.
GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. Availability never substitutes another model or rewrites a Session selection.
GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection or a failed catalog query rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation with the same error code. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. These catalog requirements belong to GUI admission; direct LLM calls can use uncatalogued models.
-----
+1 -1
View File
@@ -68,7 +68,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。
GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。不可用的选择在投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。可用性变化不会替换模型或改写会话选择。
GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。选择不可用或目录查询失败时,投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查,并使用相同错误码。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。这些目录要求属于 GUI 准入;直接 LLM 调用可使用未列入目录的模型。
-----
+13 -4
View File
@@ -1,5 +1,7 @@
/** Shared projection of the live LLM registry into the browser model catalog. */
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type { LlmModelInfo } from '@deepseek-ai/dsh-llm'
import type { Context } from '@deepseek-ai/cordis'
import type {
ModelCatalog,
@@ -57,11 +59,12 @@ export async function buildModelCatalog(
}
}
}))
const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
.filter(group => group.models.length > 0)
return {
default: { ...defaultSelection },
routableProviders: catalog.flatMap(item => item.kind === 'group' && item.group.models.length > 0 ? [item.group.id] : []),
groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
.filter(group => group.models.length > 0),
routableProviders: groups.map(group => group.id),
groups,
failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
}
}
@@ -74,6 +77,12 @@ export async function buildModelCatalog(
*/
export async function modelAvailable(ctx: Context, selection: ModelSelection): Promise<boolean> {
if (!ctx.llm.listProviders().some(provider => provider.id === selection.provider)) return false
const models = await ctx.llm.listModels(selection.provider)
let models: readonly LlmModelInfo[]
try { models = await ctx.llm.listModels(selection.provider) }
catch (error) {
throw new RemoteError('session/model-unavailable',
error instanceof Error ? error.message : String(error),
{ provider: selection.provider, model: selection.model })
}
return models.some(model => model.id === selection.model)
}
+2 -1
View File
@@ -279,7 +279,8 @@ export class SessionController extends TypertRemoteService {
async initializeDefaultModel(provider: string): Promise<void> {
const catalog = await buildModelCatalog(this.ctx)
const model = catalog.groups.find(group => group.id === provider)?.models[0]
if (model === undefined) throw new Error(`provider "${provider}" has no available models`)
if (model === undefined) throw new RemoteError('session/provider-models-unavailable',
`provider "${provider}" has no available models`, { provider })
await this.ctx.agentDefaultModel.initializeSelection({ provider, model: model.id,
...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) },
})
@@ -187,6 +187,7 @@ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
'session/provider-models-unavailable': { readonly provider: string }
'session/model-unavailable': { readonly provider: string; readonly model: string }
'session/conflict': {
readonly sessionId: SessionId
@@ -360,7 +360,9 @@ describe('Web session model selection', () => {
await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' })
await controller.initializeDefaultModel('deepseek-official')
expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' })
await expect(controller.initializeDefaultModel('empty')).rejects.toThrow('no available models')
await expect(controller.initializeDefaultModel('empty')).rejects.toMatchObject({
code: 'session/provider-models-unavailable', details: { provider: 'empty' },
})
await ctx.fiber.dispose()
})
@@ -612,6 +614,48 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it.each([1, 2])('reports catalog errors as model-unavailable at prompt check %s', async (check) => {
const { ctx, sessionId, agent } = await harness()
const followup = vi.fn()
Object.assign(agent, { followup })
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp',
})
const list = vi.spyOn(ctx.llm, 'listModels')
if (check === 2) list.mockResolvedValueOnce([{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'Chat' }])
list.mockRejectedValueOnce(new Error('credential storage offline'))
try {
expect(await remote.prompt(promptRequest({
sessionId, mode: 'queue', content: [{ type: 'text', text: 'hello' }],
}))).toMatchObject({ ok: false, error: {
code: 'session/model-unavailable', message: 'credential storage offline',
details: { provider: 'deepseek-official', model: 'deepseek-chat' },
} })
expect(list).toHaveBeenCalledTimes(check)
expect(followup).not.toHaveBeenCalled()
} finally {
list.mockRestore()
await ctx.fiber.dispose()
}
})
it('leaves uncatalogued adapters usable by core callers but unavailable to GUI selection', async () => {
const { ctx, sessionId } = await harness()
ctx.llm.registerAdapter(['uncatalogued'], new class extends LlmAdapter {
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {}
}())
try {
await expect(ctx.llm.resolveModelInfo('uncatalogued', 'custom-model')).resolves.toMatchObject({ id: 'custom-model' })
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'uncatalogued', model: 'custom-model' }), cwd: '/tmp',
})
expect(await remote.selectModel({ sessionId, provider: 'uncatalogued', model: 'custom-model' }))
.toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } })
} finally {
await ctx.fiber.dispose()
}
})
it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
const { ctx, sessionId } = await harness()
const remote = createSessionTestRemote(ctx, {
@@ -150,7 +150,7 @@ export function ConversationContent(props: ConversationContentProps) {
// `blocked`, not `disabled`: the bar refuses input either way, but a
// block keeps the model seat live because choosing a model is how the
// user clears it.
? { blocked: composerBlock, ...(hero ? { placeholder: t('placeholder.hero') } : {}) }
? { blocked: composerBlock, placeholder: t(hero ? 'placeholder.hero' : 'placeholder.default') }
: hero ? { placeholder: t('placeholder.hero') } : {}),
})
@@ -424,15 +424,14 @@ describe('ConversationRoot resident composer', () => {
expect(dispatchCount()).toBe(before)
})
it('renders the composer inert with the blocker\u2019s own reason', () => {
it('keeps the normal placeholder while model availability blocks sending', () => {
const b = mount(sessionSnapshotOf(), undefined, undefined, {
composerBlock: { reason: 'select a model first' },
})
const box = b.view.getByRole('textbox')
// One disabled composer with the blocker's placeholder, never a second
// tree: the DOM survives the block being raised and cleared.
// Blocking keeps the mounted composer and its normal placeholder.
expect(box.getAttribute('aria-disabled')).toBe('true')
expect(box.getAttribute('data-placeholder')).toBe('select a model first')
expect(box.getAttribute('data-placeholder')).toBe(zh['placeholder.default'])
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).not.toHaveBeenCalled()
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md
README.md: 3c73a42b34ad9075eaaab9e256767b7250ea0513
README.zh.md: 87cccdeacde3183427a1ff08e1d1288e5cf170be
README.md: 25945f012b2c1f6cfc0f36203fac632c01a86101
README.zh.md: 6c59586a08b7833f40491f61b5f57cd2b76e5735
+2 -2
View File
@@ -33,11 +33,11 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp
### Model and effort
Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first; other providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input.
Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input.
### Unroutable sessions
The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged.
The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Refreshes and refresh failures retain the last displayed selection and groups while sending remains blocked; retry revalidates availability. A Host reset clears that display. The account provider name follows the active locale in both selectors. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged.
Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block.
@@ -33,11 +33,11 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
### 模型与推理强度
模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号置顶,其他提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
### 不可路由的会话
确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。
确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。刷新及刷新失败期间保留上次显示的选择和分组,同时继续禁发;重试会重新确认可用性。Host 重置时清空这些显示。两个选择器中的账号提供方名称均随当前语言显示。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。
只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。
@@ -76,7 +76,8 @@ export function ModelSelect(
const id = useId()
const groups = useMemo(() => state.groups.toSorted((left, right) =>
Number(right.id === 'deepseek-account') - Number(left.id === 'deepseek-account')), [state.groups])
(left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2)
- (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)), [state.groups])
const choices = useMemo(() => groups.flatMap(group =>
group.models.map(model => ({
group,
@@ -407,7 +408,7 @@ export function ModelSelect(
)}
{state.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
<span>{t('warning.groupLoad', { name: failure.id === 'deepseek-account' ? t('provider.account') : failure.name, message: failure.message })}</span>
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
</div>
))}
@@ -416,7 +417,7 @@ export function ModelSelect(
const headingId = `${id}-${group.id}`
return (
<section role="group" aria-labelledby={headingId} className={css.group} key={group.id}>
<div className={css.groupTitle} id={headingId}>{group.name}</div>
<div className={css.groupTitle} id={headingId}>{group.id === 'deepseek-account' ? t('provider.account') : group.name}</div>
{group.models.map((model) => {
const selected = state.current?.provider === group.id && state.current.model === model.id
return (
@@ -15,7 +15,7 @@ import type { ModelCatalogDirectory } from './catalog.ts'
/** Directory snapshot both entries render from. */
export interface ModelDirectoryState {
/** Available durable selection, or the available default for an unselected Session. */
/** Last catalog-confirmed selection, retained for display during same-Host refreshes. */
current: ModelSelection | null
/** Saved effort caption retained when the selected model is unavailable. */
retainedEffort?: string
@@ -145,11 +145,11 @@ export class ModelDirectory {
: reasoning?.efforts.find(level => level.id === effort)?.name ?? effort
if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) {
this.store.set({
current: null,
current: catalog.value === null ? null : this.store.getSnapshot().current,
...retainedEffort === undefined ? {} : { retainedEffort },
routable: null,
groups: [],
failures: [],
groups: catalog.value?.groups ?? [],
failures: catalog.value?.failures ?? [],
status: catalog.status === 'error' ? 'error' : 'loading',
error: catalog.error,
})
@@ -67,12 +67,13 @@ function descriptionOf(
function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] {
const rows: SelectOption[] = []
for (const group of directory.groups) {
const name = group.id === 'deepseek-account' ? t('provider.account') : group.name
for (const model of group.models) {
const description = descriptionOf(group.id, model, t)
rows.push({
id: rowId(group.id, model.id),
label: model.name,
detail: description !== undefined ? `${group.name} · ${description}` : group.name,
detail: description !== undefined ? `${name} · ${description}` : name,
...(directory.current !== null
&& directory.current.provider === group.id
&& directory.current.model === model.id
@@ -83,7 +84,7 @@ function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): Sel
for (const failure of directory.failures) {
rows.push({
id: `failure/${failure.id}`,
label: failure.name,
label: failure.id === 'deepseek-account' ? t('provider.account') : failure.name,
detail: t('option.loadError', { message: failure.message }),
})
}
@@ -10,6 +10,7 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'provider.account': 'DeepSeek 账号',
'command.label': '模型',
'command.description': '选择本会话使用的模型',
'option.loadError': '目录加载失败:{message}',
@@ -39,6 +40,7 @@ export type ModelKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'provider.account': 'DeepSeek Account',
'command.label': 'Model',
'command.description': 'Select the model for this conversation',
'option.loadError': 'Catalog failed to load: {message}',
@@ -73,16 +73,19 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
const projections = new Map<SessionId, SnapshotStore<ModelSelectionProjection | undefined>>()
// Whether the Host advertises available models for the current route.
let routable = true
let catalogFailure = false
let groups = GROUPS
let selectionFailure: RemoteError<'session/writer-held'> | undefined
const sessionRemote = {
modelCatalog: () => {
calls.models += 1
if (catalogFailure) return Promise.reject(new Error('catalog offline'))
return Promise.resolve({
ok: true as const,
value: {
default: defaultSelection,
routableProviders: routable ? ['deepseek-official'] : [],
groups: routable ? GROUPS : [],
groups: routable ? groups : [],
failures: [],
},
})
@@ -185,6 +188,8 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection },
setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) },
address: (id: SessionId) => { addressed.add(id) },
setGroups: (next: typeof GROUPS) => { groups = next },
setCatalogFailure: (next: boolean) => { catalogFailure = next },
setRoutable: (next: boolean) => { routable = next },
blockOf: (key: string) => blocks.get(sid(key)),
}
@@ -280,6 +285,19 @@ describe('ui-model-selection dual entry', () => {
})
})
it.each(['en', 'zh'] as const)('localizes account provider details in the %s model popup', async (locale) => {
const b = await bench(locale)
try {
b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }])
b.remote.emit('llm/adapters-updated', [])
b.mint('s1')
const options = await b.popup().options(projection('s1'), new AbortController().signal)
expect(options[0]?.detail).toContain(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account')
} finally {
await b.ctx.fiber.dispose()
}
})
it('both entries share one directory instance per session, isolated across sessions', async () => {
const b = await bench()
b.mint('a')
@@ -330,7 +348,9 @@ describe('ui-model-selection dual entry', () => {
next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
})
expect(face.directory.getSnapshot()).toMatchObject({
current: null,
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
groups: GROUPS,
routable: null,
status: 'loading',
})
@@ -342,6 +362,30 @@ describe('ui-model-selection dual entry', () => {
})
})
it('retains the last catalog on refresh failure, blocks sending, and recovers on retry', async () => {
const b = await bench()
try {
b.mint('s1')
const face = b.seat().inject!(sid('s1'))
await b.ctx.modelDirectories.directoryFor(sid('s1')).load()
b.setCatalogFailure(true)
b.remote.emit('credentials/record-updated', ['DEEPSEEK_API_KEY'])
await vi.waitFor(() => {
expect(face.directory.getSnapshot()).toMatchObject({
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
groups: GROUPS, routable: null, status: 'error', error: 'catalog offline',
})
})
expect(b.blockOf('s1')).toBeDefined()
b.setCatalogFailure(false)
await b.ctx.modelDirectories.directoryFor(sid('s1')).load()
expect(face.directory.getSnapshot().routable).toBe(true)
expect(b.blockOf('s1')).toBeUndefined()
} finally {
await b.ctx.fiber.dispose()
}
})
it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => {
const b = await bench()
const first = b.mint('s1')
@@ -1,8 +1,8 @@
deepseek-account-1
deepseek-account-2
custom-1
custom-2
deepseek-official-1
deepseek-official-2
custom-1
custom-2
another-1
another-2
@@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ComponentProps } from 'react'
import type { ModelDirectoryState } from '../src/client/directory.ts'
import { ModelSelect } from '../src/client/ModelSelect.tsx'
import { zh } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
// The seat's key domain is model ∪ common; the stub mirrors the real lookup
@@ -463,7 +463,7 @@ it('shows the unselected model control with the inherited effort', async () => {
})
it('places account models first while preserving other provider and model order', async () => {
it('places account and official models before third-party models', async () => {
const groups = ['custom', 'deepseek-official', 'deepseek-account', 'another'].map(id => ({
id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })),
}))
@@ -472,9 +472,22 @@ it('places account models first while preserving other provider and model order'
fireEvent.click(screen.getByRole('button', { name: '请选择模型' }))
const names = screen.getAllByRole('menuitemradio').map(row => row.textContent)
expect(names).toEqual([
'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2',
'deepseek-official-1', 'deepseek-official-2', 'another-1', 'another-2',
'deepseek-account-1', 'deepseek-account-2', 'deepseek-official-1', 'deepseek-official-2',
'custom-1', 'custom-2', 'another-1', 'another-2',
])
expect(groups.map(group => group.id)).toEqual(['custom', 'deepseek-official', 'deepseek-account', 'another'])
await expect(`${names.join('\n')}\n`).toMatchFileSnapshot('./expected/account-first.txt')
})
it.each([en, zh])('localizes the account group while preserving external names', (copy) => {
const groups = ['deepseek-account', 'custom'].map(id => ({
id, name: id === 'deepseek-account' ? 'DeepSeek Account' : 'My Gateway',
models: [{ id: 'model', name: 'Model' }],
}))
render(<ModelSelect locked={false} available
directory={createSnapshotStore(state({ current: null, groups }))}
load={vi.fn()} select={vi.fn()} t={key => key in copy ? copy[key as keyof typeof copy] : key} />)
fireEvent.click(screen.getByRole('button', { name: copy['trigger.selectAria'] }))
expect(screen.getByRole('group', { name: copy['provider.account'] })).toBeTruthy()
expect(screen.getByRole('group', { name: 'My Gateway' })).toBeTruthy()
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md
README.md: d54687d083897842518a0c95dfc84d39e985f3a1
README.zh.md: c4dbcf234fb72e826ccd742b70740744e98e8fcb
README.md: 615d2ef3466fc748d7b8c65c65c356afdd4f3cf3
README.zh.md: 39d8a94b2e8d98896b13b8c76f7e5ff6390b5d0d
@@ -34,7 +34,7 @@ The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, a
The account card’s More account information link opens `https://platform.deepseek.com` in the system browser on Desktop or a new tab on Web.
Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed queries keep the menu available; failed sign-out keeps the dialog available for retry.
Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed impact queries still open confirmation with an explicit unknown-task warning; failed sign-out keeps the dialog available for retry.
<a id="understand-the-implementation"></a>
## Understand the implementation
@@ -74,3 +74,5 @@ No model request prefix changes.
The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership.
Usage and top-up show a centered 24px loading indicator without visible loading text until the native document loads; the return action remains available. The loading SVG is embedded locally from Figma node 2957:72553.
Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded in diagnostics and do not mark the signed-in account as failed.
@@ -34,7 +34,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
账号卡片的“更多账号信息”链接在 Desktop 中通过系统浏览器打开 `https://platform.deepseek.com`,在 Web 中打开新标签页。
退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。查询失败保留菜单;退登失败保留弹窗以便重试。
退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。影响查询失败仍打开确认弹窗,并明确提示无法确认任务状态;退登失败保留弹窗以便重试。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -74,3 +74,5 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
[桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。
用量和充值在原生页面加载完成前显示居中的 24px 加载图标,不显示加载文字;返回操作始终可用。加载 SVG 从 Figma 节点 2957:72553 内嵌到本地样式。
默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败会记录诊断日志,不会将已登录账号标记为失败。
@@ -27,12 +27,12 @@ export function AccountMenu({
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [failed, setFailed] = useState(false)
const [signOutImpact, setSignOutImpact] = useState<boolean>()
const [signOutImpact, setSignOutImpact] = useState<boolean | 'unknown'>()
const requestSignOut = async () => {
setBusy(true)
setFailed(false)
try { setSignOutImpact(await hasRunningAccountTasks()); setOpen(false) }
catch { setFailed(true) }
catch (_error) { setSignOutImpact('unknown'); setOpen(false) }
finally { setBusy(false) }
}
return <div className={css.root}>
@@ -6,7 +6,7 @@ import css from './SignInDialog.module.css'
/** @param props - task impact at opening, localized copy, and account actions. @returns sign-out confirmation. */
export function SignOutDialog({ running, signOut, close, t }: {
running: boolean
running: boolean | 'unknown'
signOut: () => Promise<void>
close: () => void
t: (key: AccountKey) => string
@@ -29,7 +29,7 @@ export function SignOutDialog({ running, signOut, close, t }: {
<IconCloseOutlineRegular size={14} />
</button>
</div>
<p className={css.description}>{t(running ? 'signOutRunningDescription' : 'signOutDescription')}</p>
<p className={css.description}>{t(running === 'unknown' ? 'signOutUnknownDescription' : running ? 'signOutRunningDescription' : 'signOutDescription')}</p>
{failed && <p className={css.description} role="alert">{t('failed')}</p>}
</div>
<div className={css.actions}>
@@ -72,19 +72,19 @@ export function apply(ctx: Context): void {
for await (const frame of stream) {
revision++
refreshing = undefined
let initializationFailed = false
if (frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded'
&& snapshot.view?.attempt?.phase !== 'succeeded') {
try {
const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account')
initializationFailed = !initialized.ok
} catch (_error) {
initializationFailed = true
}
}
publish({ ...snapshot, view: frame.value, details: undefined, failed: initializationFailed })
const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded'
&& snapshot.view?.attempt?.phase !== 'succeeded'
publish({ ...snapshot, view: frame.value, details: undefined, failed: false })
browser.update(frame.value)
frame.accept()
if (initialize) void (async () => {
try {
const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account')
if (!initialized.ok) console.info('[deepseek-account] default model initialization failed', { reason: 'refused' })
} catch (_error) {
console.info('[deepseek-account] default model initialization failed', { reason: 'disconnected' })
}
})()
void refresh()
}
})().catch(() => { if (!stream.signal.aborted) publish({ ...snapshot, failed: true }) })
@@ -10,6 +10,7 @@ export const en = {
settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu',
nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in',
signIn: 'Sign in', signOut: 'Sign out',
signOutUnknownDescription: 'Could not check running tasks. Signing out may interrupt tasks using this account. Sign out now?',
signOutDescription: 'Signing out will not delete any data. You can sign in to this account again.',
signOutRunningDescription: 'Tasks are currently running. Signing out will interrupt them. Sign out now?', cancel: 'Cancel', open: 'Open browser',
initializing: 'Starting sign in…', waiting: 'Continue in your browser',
@@ -36,6 +37,7 @@ export const zh: Record<AccountKey, string> = {
settings: '设置', contactUs: '联系我们', menu: '账号菜单',
nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录',
signIn: '登录', signOut: '退出登录',
signOutUnknownDescription: '暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?',
signOutDescription: '退出登录不会丢失任何数据,你仍可以登录此账号。',
signOutRunningDescription: '当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?', cancel: '取消', open: '打开浏览器',
initializing: '正在发起登录…', waiting: '请在浏览器中继续',
@@ -51,14 +51,14 @@ it('starts sign-in and disables cancellation during persistence', async () => {
expect(screen.queryByRole('button', { name: en.signIn })).toBeNull()
})
it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => {
it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => {
const signOut = vi.fn(() => Promise.resolve())
const openSettings = vi.fn()
const operations = mount({ status: 'credential-stored', attempt: null }, copy)
cleanup()
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} signOut={signOut}
hasRunningAccountTasks={async () => running}
hasRunningAccountTasks={async () => { if (running === 'unknown') throw new Error('offline'); return running }}
useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings}
t={key => key in copy ? copy[key as AccountKey] : key} />)
fireEvent.click(screen.getByRole('button', { name: copy.menu }))
@@ -73,7 +73,7 @@ it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }
fireEvent.click(screen.getByRole('button', { name: copy.menu }))
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: copy.signOut })) })
expect(signOut).not.toHaveBeenCalled()
expect(screen.getByText(running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy()
expect(screen.getByText(running === 'unknown' ? copy.signOutUnknownDescription : running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) })
expect(signOut).toHaveBeenCalledOnce()
expect(screen.queryByRole('menu')).toBeNull()
@@ -4,13 +4,17 @@ import { expect, it, vi } from 'vitest'
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import { apply, type AccountSectionInjected } from '../src/client/index.ts'
it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => {
it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => {
const log = vi.spyOn(console, 'info').mockImplementation(() => {})
const observed: { status: string | undefined; failed: boolean | undefined }[] = []
const abort = new AbortController()
const done = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const initialization = Promise.withResolvers<undefined>()
const initializeDefaultModel = vi.fn(async () => {
if (outcome === 'pending') await initialization.promise
if (outcome === 'disconnected') throw new Error('connection lost')
return { ok: outcome === 'accepted', value: undefined }
return { ok: outcome === 'accepted' || outcome === 'pending', value: undefined }
})
const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' }
const signedIn: AccountView = {
@@ -38,7 +42,11 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m
$stream: () => ({
signal: abort.signal, dispose: () => { abort.abort(); release.resolve(undefined) },
async *[Symbol.asyncIterator]() {
for (const value of frames) yield { value, accept: () => { if (value.status === 'signed-out') done.resolve(undefined) } }
for (const value of frames) yield { value, accept: () => {
const snapshot = operations?.hooks.account.getSnapshot()
observed.push({ status: snapshot?.view?.status, failed: snapshot?.failed })
if (value.status === 'signed-out') done.resolve(undefined)
} }
await release.promise
},
}),
@@ -48,8 +56,20 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m
apply(ctx as never)
await done.promise
expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account')
expect(observed).toEqual([
{ status: 'credential-stored', failed: false },
{ status: 'credential-stored', failed: false },
{ status: 'signed-out', failed: false },
])
if (outcome === 'accepted' || outcome === 'pending') expect(log).not.toHaveBeenCalled()
else expect(log).toHaveBeenCalledWith('[deepseek-account] default model initialization failed', {
reason: outcome === 'refused' ? 'refused' : 'disconnected',
})
expect(operations?.hooks.account.getSnapshot()).toMatchObject({ view: { status: 'signed-out' }, failed: false })
} finally {
initialization.resolve(undefined)
await Promise.resolve()
for (const dispose of disposers.reverse()) dispose()
log.mockRestore()
}
})
@@ -0,0 +1 @@
Sign outCould not check running tasks. Signing out may interrupt tasks using this account. Sign out now?CancelSign out
@@ -0,0 +1 @@
退出登录暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?取消退出登录
@@ -6,16 +6,16 @@ import { SignOutDialog } from '../src/client/SignOutDialog.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
function mount(running: boolean, copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) {
function mount(running: boolean | 'unknown', copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) {
const close = vi.fn()
render(<SignOutDialog running={running} signOut={signOut} close={close} t={key => copy[key]} />)
return { signOut, close }
}
it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))(
it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))(
'confirms sign-out with localized task impact: $running', async ({ copy, running }) => {
const props = mount(running, copy)
await expect(`${screen.getByRole('dialog').textContent}\n`)
.toMatchFileSnapshot(`./expected/sign-out-${running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`)
.toMatchFileSnapshot(`./expected/sign-out-${running === 'unknown' ? 'unknown' : running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`)
expect(props.signOut).not.toHaveBeenCalled()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) })
expect(props.signOut).toHaveBeenCalledOnce()
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
README.md: ccce6981a618fd2dfa451f1600ae19e443d77973
README.zh.md: 0d2f64a1fe4583493b192f33f128f86157a65f8f
README.md: 289a0ec0b3603590dddcab848a79235ca4579bcf
README.zh.md: 10c61a464d48933a692a265b5d5b0812bf79cdbb
@@ -25,6 +25,10 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Saving credentials or a custom provider completes independently of default-model initialization. Initialization failures are logged; they do not report a successful save as failed or prevent closing the editor. The user can select an available model from the composer.
DeepSeek Account appears first and DeepSeek second in the provider list; third-party providers retain their directory order.
Open the Models page from the Settings navigation to see every configured provider as a row. A whole-section provider whose key is not configured anywhere renders as its open setup card instead, but only in the first-run posture and only until the user closes that card. Each card kind owns its own open state, so closing one never discards a draft in another.
A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic.
@@ -25,6 +25,10 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
凭据或自定义提供方保存成功后,不等待默认模型初始化即可完成操作。初始化失败记录日志,不会将已成功的保存报告为失败,也不会阻止编辑器关闭。用户可在 composer 中选择可用模型。
提供方列表中 DeepSeek 账号排第一,DeepSeek 排第二;第三方提供方保留目录中的相对顺序。
从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。
存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。
@@ -178,7 +178,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
// and the retry above now goes straight back to this write.
if (stored !== undefined) return stored
}
return operations.initializeModel(route.trim())
void operations.initializeModel(route.trim())
return undefined
}
const create = async (): Promise<void> => {
@@ -288,8 +288,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
if (keyValue.length > 0) {
const stored = await operations.storeCredential(keyRef, keyValue)
if (stored !== undefined) return stored
const initialized = await operations.initializeModel(props.provider)
if (initialized !== undefined) return initialized
void operations.initializeModel(props.provider)
}
setKeyDraft('')
return undefined
@@ -32,8 +32,11 @@ export type ModelDiscoveryOutcome =
/** The Host operations the Models page and its cards invoke. */
export interface ModelsOperations {
/** @param provider - newly configured provider. @returns refusal text, or undefined after initialization. */
initializeModel(provider: string): Promise<string | undefined>
/**
* @param provider - newly configured provider.
* @returns after optional initialization; failures are logged without changing the save outcome.
*/
initializeModel(provider: string): Promise<void>
/**
* Read one credential reference's state.
* @param ref - credential reference name.
@@ -84,8 +87,12 @@ export interface ModelsOperations {
export function createModelsOperations(ctx: ClientContext): ModelsOperations {
return {
initializeModel: async (provider) => {
const response = await ctx.remote.session.initializeDefaultModel(provider)
return response.ok ? undefined : response.error.message
try {
const response = await ctx.remote.session.initializeDefaultModel(provider)
if (!response.ok) console.info('[models] default model initialization failed', { provider, reason: 'refused' })
} catch (_error) {
console.info('[models] default model initialization failed', { provider, reason: 'disconnected' })
}
},
describeCredential: async (ref) => {
const response = await ctx.remote.credentials.describe([ref])
@@ -37,7 +37,7 @@ export interface ProviderDirectoryEntry {
* Join declared configurable providers with the currently registered routes.
* @param registered - live provider routes in registration order.
* @param directory - declared configurable providers in declaration order.
* @returns declared rows followed by live routes with no declaration.
* @returns account and official routes first, then other routes in their original order.
*/
export function joinProviderDirectory(
registered: readonly LlmProviderInfo[],
@@ -64,7 +64,9 @@ export function joinProviderDirectory(
active: true,
})
}
return rows
return rows.toSorted((left, right) =>
(left.provider === 'deepseek-account' ? 0 : left.provider === 'deepseek-official' ? 1 : 2)
- (right.provider === 'deepseek-account' ? 0 : right.provider === 'deepseek-official' ? 1 : 2))
}
/** One provider row the page renders. */
@@ -1111,6 +1111,29 @@ describe('ModelsSection', () => {
expect(baseURL.value).toBe('')
})
it.each(['refused', 'disconnected', 'pending'] as const)('finishes credential saving when default initialization is %s', async (outcome) => {
const log = vi.spyOn(console, 'info').mockImplementation(() => {})
const pending = Promise.withResolvers<never>()
const { ctx, set } = await mountDeepSeekCard()
const initialize = vi.spyOn(ctx.remote.session, 'initializeDefaultModel')
if (outcome === 'pending') initialize.mockReturnValue(pending.promise)
else if (outcome === 'disconnected') initialize.mockRejectedValue(new Error('offline'))
else initialize.mockResolvedValue({ ok: false,
error: new RemoteError('session/provider-models-unavailable', 'empty catalog', { provider: 'deepseek-official' }) })
try {
fireEvent.change(await screen.findByLabelText(en.keyInput), { target: { value: 'test-key' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() })
expect(set).toHaveBeenCalledOnce()
expect(initialize).toHaveBeenCalledExactlyOnceWith('deepseek-official')
expect(screen.queryByText('empty catalog')).toBeNull()
} finally {
if (outcome === 'pending') { pending.reject(new Error('closed')); await Promise.resolve() }
initialize.mockRestore()
log.mockRestore()
}
})
it('rejects an invalid draft before writing', async () => {
const { mutate } = await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
@@ -847,6 +847,30 @@ describe('hand-declared providers', () => {
return { ...scripted, onClose }
}
it('closes a saved custom provider when default initialization is refused', async () => {
const log = vi.spyOn(console, 'info').mockImplementation(() => {})
const { face, mutate, set, onClose } = mountCard()
const initialize = vi.spyOn(ctxWith(face).remote.session, 'initializeDefaultModel')
.mockResolvedValue({ ok: false, error: new RemoteError('session/provider-models-unavailable',
'empty catalog', { provider: 'acme-gateway' }) })
try {
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1' } })
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'model' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
expect(mutate).toHaveBeenCalledOnce()
expect(set).toHaveBeenCalledOnce()
expect(initialize).toHaveBeenCalledExactlyOnceWith('acme-gateway')
expect(screen.queryByText('empty catalog')).toBeNull()
} finally {
initialize.mockRestore()
log.mockRestore()
}
})
it('writes the whole profile and the key under the derived reference', async () => {
const { mutate, set, onClose } = mountCard()
@@ -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<T>(value: T): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
@@ -1398,7 +1398,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async listModels(provider: string): Promise<LlmModelInfo[]>',
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.',
},
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
README.md: 7ded16b5e1d8c87b924aeb02c8eda0a3033bd3ee
README.zh.md: de612f7b2153eab35e8a00db71aeb1bb92824eb1
README.md: be2694c0f7cd1fd19b7f17cabb0e2598e995a065
README.zh.md: e07afc9f9084c04bd0605de284e40c4f8c44b445
+1 -1
View File
@@ -47,7 +47,7 @@ Choose this adapter for DeepSeek's official API or a gateway that supports the s
filesApiTimeoutMs: 60000
```
A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and unlisted model ids still pass through as text-only routes. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "<id>" systemPromptUpdate must be "in-history" when present`.
A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and core calls still pass unlisted model ids through as text-only routes. GUI selection and submission require a catalog entry. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "<id>" systemPromptUpdate must be "in-history" when present`.
| Field | Default | Meaning |
|---|---|---|
+1 -1
View File
@@ -47,7 +47,7 @@ kind: "package-reference"
filesApiTimeoutMs: 60000
```
请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,未列出的模型 id 仍作为纯文本路由原样通过。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "<id>" 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 "<id>" systemPromptUpdate must be "in-history" when present` 失败。
| 字段 | 默认值 | 含义 |
|---|---|---|
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: cdda682cebc720d51692a1b0c4c8691fa84359a3
README.zh.md: 8c679ecc6910376f7270450ae5be867d0da353fc
README.md: 1abdc8e332410ccd69066af6944bc4a32221635e
README.zh.md: 320a232c13fd145b65d53860c0cb1a4f7bfeb5a7
+2
View File
@@ -25,6 +25,8 @@ Use `@deepseek-ai/dsh-llm` to stream model calls through configured provider ada
<a id="use-this-package"></a>
## 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
+2
View File
@@ -25,6 +25,8 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
`listModels` 描述目录驱动界面提供的模型。核心解析与流式调用仍可接受未列出的 ID。GUI 的模型选择与提交要求模型出现在目录中;供 GUI 使用的适配器必须实现 `listModels`,公布其可用模型。基类实现返回空列表,因此不向 GUI 提供模型。
任何调用模型提供方的组合——agent loop(智能体循环)、会话标题生成器、压缩(compaction)摘要器——都会通过本服务流式发起请求。与至少一个提供方适配器一起挂载它;服务本身没有任何配置,也不包含提供方协议代码。
### 何时选择
+5 -3
View File
@@ -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.
*/