feat(llm): follow the official /models listing on the DeepSeek API-key route (#6944)

* refactor(llm-deepseek): share the /models listing machinery

Move the account route's `/models` request and decoding, its
credential-scoped listing cache, and listed-catalog resolution with the
version gate into the shared DeepSeek Messages library, so a second
route can follow the same listing. Names drop the account prefix, the
cache takes each access's endpoint settings with the call, and it gains
`invalidate()` for a forced reload. `isBuiltInCatalog()` recognizes the
catalog an uncustomized `models` setting resolves to.

The account route keeps its behavior; only its imports and the
override diagnostic wording ("model listing") change.

* feat(llm-deepseek-api-key): follow the official /models listing

While `models` equals the built-in catalog and the Messages origin is
https://api.deepseek.com, `deepseek-official` serves the official
`GET /models` listing, authenticated with the API key in `x-api-key`.
Loading, refresh, fallback, merging, and the client-version gate match
the account route: the first access waits up to `modelsTimeoutMs`, a
listing older than `modelsMaxAgeMs` refreshes in the background on the
next access, a failure keeps the last good listing or the built-in
catalog, and listed models this build cannot serve are refused before
network I/O.

A customized `models` list replaces the catalog and requests nothing.
Switching between a customized catalog and the listing discards the
cached listing, so Restore defaults reloads it on the next access.

Suites that boot the route on the official endpoint now answer the
listing themselves instead of reaching the network.

* docs: describe the official route model listing

Document when `deepseek-official` follows the official `/models`
listing, its timing fields, customization precedence, and the forced
reload after Restore defaults in the API-key README, the shared
transport README, and the provider guide. Record the decision in a new
Agent Note, keep the account note's statement of where the shared
listing code lives current, and regenerate the configuration catalog.

* test(llm-deepseek-api-key): pin the once-per-listing hidden-entry diagnostic

* docs: regenerate module and event graphs for the shared listing

* fix(llm-deepseek-api-key): report credential failures on the model request

On the official endpoint, `connect` waited for the listing view, whose
credential lookup rethrew a malformed key or a credential-store failure
before the agent loop recorded the prompt. A failed lookup now resolves
the connection from the current listing view, so `resolveAuth` reports
the failure on the model request and the turn keeps the prompt and the
failed attempt. A caller abort still rejects.

* docs(llm-deepseek-api-key): name which hidden listing entries are logged

* test: keep unit suites off the official model listing

Suites that boot the DeepSeek routes with a dummy or inherited key now
request `GET https://api.deepseek.com/models` on the official endpoint.
A unit-lane setup file rejects that request at once, so such a suite
serves the built-in catalog instead of reaching production or waiting
out the listing deadline on a network that never answers; suites that
exercise the listing answer `fetch` themselves. Other requests pass
through unchanged.

* feat(llm-deepseek): read tool_update from the model listing

Both DeepSeek routes now take a listed model's Messages tool update mode
from `api_capabilities.anthropic_messages.tool_update`, alongside the
system prompt mode. `in-history` and `addition-only` replace the
bundled mode; any other value reads as `leading-only` and removes it.
An absent or `null` mode keeps the bundled one, so a listing that does
not declare the field leaves `deepseek-flash` on `addition-only`.

`system_prompt_update: null` likewise keeps the bundled mode now; it
previously read as `leading-only`, which a serializer writing empty
values as null would have turned into a cache-unfriendly downgrade.

* fix(llm-deepseek): sync the built-in context window with the official listing

The official `/models` listing declares a 1,048,576-token context window
for `deepseek-flash` and `deepseek-v4-pro`; the built-in catalog said
1,000,000, a decimal-versus-binary mismatch. Both built-in entries now
carry the listed value, so offline starts and listing failures size the
context the same way the listing does. The Models page shows the exact
count for these rows, since 1,048,576 has no decimal K/M spelling.

`defaultContextWindow`, the fallback for model ids the catalog does not
describe, stays 1,000,000.

* test(llm-deepseek-api-key): reject the stubbed listing request with an Error

* test(cli): keep the built SDK smoke off the official model listing

The SDK profile's initialize now resolves the deepseek-official catalog,
which requests GET https://api.deepseek.com/models on the official
endpoint. With the smoke's fixture key that request answers 401 and the
route warns on stderr, so the test's empty-stderr assertion failed in the
e2e and snapshots lanes. Point the run at a loopback mock and assert it
received no model call.

* test(llm-deepseek): compose the malformed-key turn without the native executor

release/dsh-0.2.1 predates @deepseek-ai/dsh-tool-executor-native; its tool
runtime executes tools itself. Compose the official-route turn the way
account-routing.spec.ts does on this branch.

* fix(llm-deepseek-api-key): gate nothing when the credential lookup fails

The listing cache switches scope only after it reads a credential, so a
failed lookup left `connect` serving the previous credential's listing.
After a key whose listing refuses a model was replaced by a malformed one,
`admit` refused that model with CLIENT_UPGRADE_REQUIRED before the turn
recorded the prompt, instead of `resolveAuth` reporting the credential
error on the request. Serve the built-in catalog without listing gates
when the lookup fails.

* test(llm-deepseek-api-key): pin the once-per-listing diagnostic across request preparation

The hidden-entry diagnostic test repeated the same discovery assertion to
prove a second pass reports nothing new, which read as a duplicate. Assert
the single warning after the request preparations instead; they resolve
the same listing, so dropping the per-listing deduplication still fails
the test.

* docs(llm-deepseek-api-key): note that a list matching an earlier built-in catalog counts as customized

Customization is judged against the running version's built-in catalog,
so a saved `models` list equal to an earlier version's catalog stops
following the listing once the built-in catalog changes, until Restore
defaults. Record the consequence in the Agent Note and the README's known
limitations.
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-10-22 20:42:19 +08:00
1 parent 39d2903841
commit c48f8523f2
57 files changed
+1010 -318

No files matched your search

@@ -9,8 +9,8 @@
en: 2eeb916eb65c66e7
zh: 7bf5c0abb8bb61e4
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/decision:
en: 42e800c4cd3b6ebe
zh: 06fabe5a9681fa27
en: 0125e33ba966c8ac
zh: bf6af389336999af
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/alternatives-considered:
en: 7c7880fdb0cc95f6
zh: 2b8a539192626789
@@ -12,8 +12,8 @@ The account route's catalog was deployment configuration: a build advertised the
- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.md) loads the listing lazily for each credential, waits for it once up to `modelsTimeoutMs`, refreshes it in the background after `modelsMaxAgeMs`, keeps the last good listing when a load fails or returns no model, and serves the bundled catalog before any listing loads. The cache lives in memory and is keyed by endpoint and token fingerprint, so per-account rollouts stay separate.
- A listed model is available when its `client_requirements["deepseek-harness"].min_version` is at most the current DeepSeek Harness version in semantic-version order. Without that declaration, only ids the built-in catalog knows are available; the others stay hidden, because a client cannot tell which future version supports them. A request for a refused listed model fails before network I/O with `CLIENT_UPGRADE_REQUIRED` or `MODEL_UNSUPPORTED`.
- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge such as tool-update mode and image budgets; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it.
- The shared transport gains only two provider hooks, `connect` and `admit`; listing, gating, and merging stay in the account package.
- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge the listing does not declare, such as image budgets and a protocol mode the listing omits or sets to `null`; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it.
- The shared transport gains only two provider hooks, `connect` and `admit`. Listing, gating, and merging live in the shared transport, which the [official route](2026-10-22-deepseek-official-model-listing.md) also uses.
## Alternatives considered
@@ -12,8 +12,8 @@ Status: implemented
- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.zh.md) 按凭据懒加载列表,首次访问最多等待 `modelsTimeoutMs`,超过 `modelsMaxAgeMs` 后在后台刷新;获取失败或返回的列表中没有模型时保留上一份,还没有加载到任何列表时使用内置目录。缓存只在内存中,以端点和 token 指纹为键,因此按账号灰度互不干扰。
- 列出的模型在其 `client_requirements["deepseek-harness"].min_version` 按 semver 顺序不高于当前 DeepSeek Harness 版本时可用。没有这个声明时,只有内置目录认识的 id 可用,其余保持隐藏,因为客户端无法判断将来哪个版本会支持它们。请求被拒绝的已列出模型时,在网络请求之前以 `CLIENT_UPGRADE_REQUIRED` 或 `MODEL_UNSUPPORTED` 失败。
- 列表中的能力逐字段覆盖内置能力;内置条目保留工具更新模式、图片预算等客户端知识;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。
- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子;列表、门控与合并都留在账号包中。
- 列表中的能力逐字段覆盖内置能力;内置条目保留列表没有声明的客户端知识,例如图片预算,以及列表没有给出或设为 `null` 的协议模式;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。
- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子。列表、门控与合并位于共享传输包中,[官方路由](2026-10-22-deepseek-official-model-listing.zh.md)也使用它们。
## 考虑过的替代方案
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-22-deepseek-official-model-listing.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-22-deepseek-official-model-listing.md
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized:
en: 20de6c2a1bc643b4
zh: 2fe21450cebf7f32
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/problem:
en: 51a513e6134e6ad9
zh: e0b19412c9b88e24
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/decision:
en: ceb2f960085577c3
zh: 1bbc833b7cf24070
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/alternatives-considered:
en: fa126e4f4e0790b4
zh: c4f6d266d1ba6753
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/consequences:
en: 047e30a0f55acb24
zh: 804e15532d172157
@@ -0,0 +1,36 @@
# Agent Note: The official DeepSeek route follows the official model listing until its catalog is customized
Status: implemented
English | [中文](2026-10-22-deepseek-official-model-listing.zh.md)
## Problem
The `deepseek-official` route offered whatever its `models` setting held, and that setting defaults to the catalog bundled with each build. A model the DeepSeek API added reached API-key users only through a release, while the account route already followed the service's `GET /models` listing at the same origin. Unlike the account route's catalog, the official route's catalog is user-editable: the Models page stores an edited catalog in `models`, and a gateway behind `baseURL` declares its models there.
## Decision
- [`llm-deepseek-api-key`](../../../../packages/llm/llm-deepseek-api-key/README.md) follows `GET https://api.deepseek.com/models`, authenticated with the API key in `x-api-key`, only while the Messages origin is `https://api.deepseek.com` and `models` equals the built-in catalog. Any other endpoint or catalog sends no listing request.
- Loading, refresh, fallback, merging, and the client-version gate are the [account route's](2026-09-24-deepseek-account-remote-model-catalog.md): a first access waits up to `modelsTimeoutMs`, a listing older than `modelsMaxAgeMs` refreshes in the background on the next access, a failure keeps the last good listing or the built-in catalog, and the cache lives in memory, keyed by endpoint and key fingerprint.
- A customized `models` list wins whole: it replaces the catalog and stops listing requests. A list is customized when it resolves differently from the built-in catalog, because the schema default materializes an absent `models` as the built-in list and the Models page shows that default as its inherited rows.
- Switching between a customized catalog and the listing invalidates the cache from a `loader/volatile-update` listener, so Restore defaults reloads the listing on the next access even when the cached listing is fresh.
- The listing request, its cache, catalog resolution, and the gate live in the shared [Messages transport](../../../../packages/llm/llm-deepseek/README.md) because two routes consume them; the transport's provider hooks remain `connect` and `admit`.
## Alternatives considered
**Follow the listing on every endpoint.** A gateway that publishes an OpenAI-style `/models` returns entries without `api_capabilities.anthropic_messages`, which the decoder hides, so the route would offer no models. Every such gateway would also receive an extra request carrying the key.
**Merge the customized catalog with the listing by model id.** Customized users would keep receiving new listed models, but a model the user deleted would return, and the Models page cannot hide one listed model.
**Mark an unset `models` by removing its schema default.** An absent value would select the listing without comparing catalogs, but the Models page would lose the inherited rows it shows and copies, with their hidden `systemPromptUpdate` and `toolUpdate` fields, into a customized catalog. Rows copied from the generic model-discovery API would drop those fields.
**Fetch once per process, or refresh on a timer.** A single fetch leaves a long-running Web or desktop host on its startup catalog, and one transient failure pins it to the built-in catalog. A timer sends requests while the route is idle. Access-triggered background refresh matches the account route.
**Reuse the cached listing after Restore defaults.** The cache could serve a listing loaded before the customization for up to `modelsMaxAgeMs`; the request is cheap, so restoring always reloads it. The reload starts on the next access rather than inside the listener, which keeps credential lookup and its failures on the request path; the Web page's settings refresh makes that access right after the save.
## Consequences
- API-key users on the official endpoint receive listed models without a release, and an older build hides or refuses a model that needs a newer client, as account users' builds do.
- A customized catalog receives no later listed models until Restore defaults, and a `models` list identical to the built-in catalog counts as uncustomized. The comparison uses the running version's built-in catalog, so a saved list equal to an earlier version's built-in catalog counts as customized once the built-in catalog changes.
- The Models page shows the built-in rows while the listing is followed, so a model only the listing offers appears only in model selectors, and editing the page's catalog drops it.
- The first catalog access on the official endpoint can wait up to `modelsTimeoutMs`, and a suite that boots the route there must answer the listing itself to stay off the network.
@@ -0,0 +1,36 @@
# Agent Note: DeepSeek 官方路由在目录未自定义时跟随官方模型列表
Status: implemented
[English](2026-10-22-deepseek-official-model-listing.md) | 中文
## 问题
`deepseek-official` 路由提供的是 `models` 设置里的内容,而这个设置默认是每个构建自带的目录。DeepSeek API 新增的模型只能随发版到达 API key 用户,而账号路由已经在同一个 origin 上跟随服务端的 `GET /models` 列表。与账号路由不同,官方路由的目录可以由用户编辑:模型页把编辑后的目录存进 `models`,`baseURL` 后面的网关也在这里声明自己的模型。
## 决策
- [`llm-deepseek-api-key`](../../../../packages/llm/llm-deepseek-api-key/README.zh.md) 只在 Messages 的 origin 是 `https://api.deepseek.com`、且 `models` 等于内置目录时,跟随 `GET https://api.deepseek.com/models`,并用 `x-api-key` 携带 API key 鉴权。其他端点或目录不发列表请求。
- 加载、刷新、兜底、合并与客户端版本门控沿用[账号路由](2026-09-24-deepseek-account-remote-model-catalog.zh.md)的规则:首次访问最多等待 `modelsTimeoutMs`,列表超过 `modelsMaxAgeMs` 后在下一次访问时后台刷新,失败时保留上一份成功的列表或使用内置目录,缓存只在内存中,以端点和 key 指纹为键。
- 自定义的 `models` 列表整份优先:它替换目录,并停止列表请求。解析结果与内置目录不同的列表才算自定义,因为 schema 默认值会把缺省的 `models` 物化为内置列表,模型页也把这个默认值显示为继承的行。
- 在自定义目录与列表之间切换时,一个 `loader/volatile-update` 监听器会让缓存失效,因此点恢复默认后,即使缓存的列表尚未过期,下一次访问也会重新加载。
- 列表请求、缓存、目录解析和门控放在共享的 [Messages 传输包](../../../../packages/llm/llm-deepseek/README.zh.md)中,因为现在有两条路由使用它们;传输层的提供方钩子仍然只有 `connect` 和 `admit`。
## 考虑过的替代方案
**在所有端点上跟随列表。** 发布 OpenAI 风格 `/models` 的网关返回的条目没有 `api_capabilities.anthropic_messages`,解码器会隐藏它们,路由就不提供任何模型。每个这样的网关还会多收到一个携带 key 的请求。
**按模型 id 合并自定义目录与列表。** 自定义用户会继续收到新列出的模型,但用户删掉的模型会重新出现,而模型页无法隐藏某一个列出的模型。
**去掉 `models` 的 schema 默认值来标记未设置。** 缺省值不用比较目录就能选中列表,但模型页会失去它显示的继承行;这些行连同隐藏的 `systemPromptUpdate`、`toolUpdate` 字段会被复制进自定义目录。从通用模型发现 API 复制的行会丢掉这些字段。
**每个进程只获取一次,或按定时器刷新。** 只获取一次会让常驻的 Web 或桌面宿主停留在启动时的目录,一次偶发失败就会让它固定在内置目录。定时器会在路由空闲时发请求。由访问触发的后台刷新与账号路由一致。
**恢复默认后沿用缓存的列表。** 缓存可能在 `modelsMaxAgeMs` 内一直提供自定义之前加载的列表;列表请求代价很小,所以恢复默认总是重新加载。重新加载从下一次访问开始,而不是在监听器里进行,这样凭据查询及其失败都留在请求路径上;Web 页面保存后的设置刷新会立即发起这次访问。
## 影响
- 使用官方端点的 API key 用户无需发版就能看到列出的模型,旧构建会像账号用户的构建一样隐藏或拒绝需要新版客户端的模型。
- 自定义目录在恢复默认之前收不到之后列出的模型;与内置目录完全相同的 `models` 列表视为未自定义。比较的是当前版本的内置目录,所以保存的列表若等于旧版本的内置目录,内置目录变化后就算作自定义。
- 跟随列表期间,模型页仍显示内置的行,因此只有列表提供的模型只出现在模型选择器里,在模型页编辑目录会丢掉它。
- 官方端点上的第一次目录访问可能等待最多 `modelsTimeoutMs`,在该端点上启动路由的测试套件必须自己应答列表请求,才能不访问网络。
+6
View File
@@ -443,6 +443,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}, SPAWN_TIMEOUT_MS + 30_000)
it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => {
// On the official endpoint the route requests its model listing with this key;
// the loopback mock keeps the run offline and records any model call.
const server = await startMockLlmServer({ sequence: ['success'] })
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
const pluginPath = join(home, 'plugin #100%.mjs')
const marker = join(home, 'plugin-loaded')
@@ -465,6 +468,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'built-sdk-profile-no-call',
DEEPSEEK_BASE_URL: server.baseURL,
},
extendEnv: false,
})
@@ -504,10 +508,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.signal, stderr).toBeUndefined()
expect(result.exitCode, stderr).toBe(0)
expect(stderr).toBe('')
expect(server.requests).toEqual([])
expect(readFileSync(marker, 'utf8')).toBe('loaded')
} finally {
child.kill('SIGKILL')
await child
await server.close()
rmSync(home, { recursive: true, force: true })
}
}, SPAWN_TIMEOUT_MS + 30_000)
@@ -36,7 +36,7 @@
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 1M
- text: 1M
- text: "1048576"
- text: 最大输出 token 数
- textbox "最大输出 token 数 1":
- /placeholder: 256K
+6 -16
View File
@@ -1730,25 +1730,15 @@ export interface Config {
## `@deepseek-ai/dsh-llm-deepseek-account`
- `inject`: `llm`
- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts)
- `refs`: [`ModelListingConfig`](../packages/llm/llm-deepseek/src/index.ts) · [`ModelOverride`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:15`](../packages/llm/llm-deepseek-account/src/config.ts)
```ts config-catalog
/** Account route configuration; authentication comes exclusively from the account service. */
export interface Config extends Omit<ProtocolConfig, 'models'> {
/** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */
modelsTimeoutMs: Volatile<number>
/**
* Listing age after which the next access refreshes it in the background;
* a failed load retries after the same age (default ten minutes).
*/
modelsMaxAgeMs: Volatile<number>
export interface Config extends Omit<ProtocolConfig, 'models'>, ModelListingConfig {
/** Field overrides by model id, layered over the account listing and the built-in catalog. */
modelOverrides: Volatile<Record<string, ModelOverride>>
}
/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */
export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-llm-deepseek-account -->
@@ -1758,12 +1748,12 @@ export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
## `@deepseek-ai/dsh-llm-deepseek-api-key`
- `inject`: `llm`
- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-api-key/src/config.ts:10`](../packages/llm/llm-deepseek-api-key/src/config.ts)
- `refs`: [`ModelListingConfig`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-api-key/src/config.ts:18`](../packages/llm/llm-deepseek-api-key/src/config.ts)
```ts config-catalog
/** Messages configuration with a per-request API-key reference. */
export interface Config extends ProtocolConfig {
export interface Config extends ProtocolConfig, ModelListingConfig {
/** Credential reference resolved per request; defaults to DEEPSEEK_API_KEY. */
apiKeyEnv: Volatile<string>
}
+6 -16
View File
@@ -1732,25 +1732,15 @@ export interface Config {
## `@deepseek-ai/dsh-llm-deepseek-account`
- `inject`: `llm`
- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts)
- `refs`: [`ModelListingConfig`](../packages/llm/llm-deepseek/src/index.ts) · [`ModelOverride`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:15`](../packages/llm/llm-deepseek-account/src/config.ts)
```ts config-catalog
/** Account route configuration; authentication comes exclusively from the account service. */
export interface Config extends Omit<ProtocolConfig, 'models'> {
/** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */
modelsTimeoutMs: Volatile<number>
/**
* Listing age after which the next access refreshes it in the background;
* a failed load retries after the same age (default ten minutes).
*/
modelsMaxAgeMs: Volatile<number>
export interface Config extends Omit<ProtocolConfig, 'models'>, ModelListingConfig {
/** Field overrides by model id, layered over the account listing and the built-in catalog. */
modelOverrides: Volatile<Record<string, ModelOverride>>
}
/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */
export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-llm-deepseek-account -->
@@ -1760,12 +1750,12 @@ export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
## `@deepseek-ai/dsh-llm-deepseek-api-key`
- `inject`: `llm`
- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-api-key/src/config.ts:10`](../packages/llm/llm-deepseek-api-key/src/config.ts)
- `refs`: [`ModelListingConfig`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-api-key/src/config.ts:18`](../packages/llm/llm-deepseek-api-key/src/config.ts)
```ts config-catalog
/** Messages configuration with a per-request API-key reference. */
export interface Config extends ProtocolConfig {
export interface Config extends ProtocolConfig, ModelListingConfig {
/** Credential reference resolved per request; defaults to DEEPSEEK_API_KEY. */
apiKeyEnv: Volatile<string>
}
+1 -1
View File
@@ -103,7 +103,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/service` | - | `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
<!-- END GENERATED event-producer-consumer:undeclared -->
+1 -1
View File
@@ -105,7 +105,7 @@
| `internal/service` | - | `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
<!-- END GENERATED event-producer-consumer:undeclared -->
+1 -2
View File
@@ -716,7 +716,6 @@ flowchart TD
pkg_llm_deepseek_account --> pkg_launch_environment
pkg_llm_deepseek_account --> pkg_llm
pkg_llm_deepseek_account --> pkg_llm_deepseek
pkg_llm_deepseek_account --> pkg_timeout
pkg_tools --> pkg_agent
pkg_tools --> pkg_llm
pkg_tools --> pkg_ptc_runtime
@@ -1658,7 +1657,7 @@ flowchart TD
| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) |
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
+1 -2
View File
@@ -718,7 +718,6 @@ flowchart TD
pkg_llm_deepseek_account --> pkg_launch_environment
pkg_llm_deepseek_account --> pkg_llm
pkg_llm_deepseek_account --> pkg_llm_deepseek
pkg_llm_deepseek_account --> pkg_timeout
pkg_tools --> pkg_agent
pkg_tools --> pkg_llm
pkg_tools --> pkg_ptc_runtime
@@ -1660,7 +1659,7 @@ flowchart TD
| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) |
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
+2 -2
View File
@@ -6,8 +6,8 @@
en: b1631dcf25e805a1
zh: 8ded02e2ae87f00b
/configure-models/configure-deepseek:
en: 2c6b665adc256b4e
zh: 2240e4c1573ebe9c
en: 44852da64a6a39ec
zh: 4c280e428f0fec08
/configure-models/add-a-third-party-provider:
en: f6fb44a85dc5e4dd
zh: 2b86ea4492c32ed7
+2
View File
@@ -12,6 +12,8 @@ Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter
Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference.
With a key saved and the default endpoint, DeepSeek's models follow the list the DeepSeek API publishes. The list loads when the models are first needed, refreshes in the background about every ten minutes while they are in use, and falls back to the built-in models when it cannot load. Editing **Models** under **Customized settings** keeps your own list instead and stops following the published one; **Restore defaults** follows it again and reloads it. While the published list is in use, the card shows the built-in models, so a newly published model appears only in the model picker. The [API-key provider reference](../../../packages/llm/llm-deepseek-api-key/README.md) owns the listing, fallback, and gating rules.
## Add a third-party provider
Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list.
+2
View File
@@ -12,6 +12,8 @@
密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。
保存了密钥且使用默认端点时,DeepSeek 的模型跟随 DeepSeek API 发布的列表。第一次需要模型时加载列表,使用期间约每十分钟在后台刷新一次,无法加载时退回内置模型。在**自定义设置**里编辑**模型目录**会改用你自己的列表,不再跟随发布的列表;点**恢复默认模型**会重新跟随并重新加载。使用发布的列表时,卡片上显示的是内置模型,因此新发布的模型只出现在模型选择器中。列表、兜底和门控规则见 [API key 提供方参考](../../../packages/llm/llm-deepseek-api-key/README.zh.md)。
## 添加第三方提供商
选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。
@@ -15,11 +15,11 @@
en: 23386452a66b2fbd
zh: 4cf93c4bf17b3da2
/deepseek-ai-dsh-llm-deepseek-account/use-this-package:
en: f0ac16310ce5a5c4
zh: 6929f80d830c18df
en: d757388f7b390d3f
zh: 2945ff97a716f91d
/deepseek-ai-dsh-llm-deepseek-account/understand-the-implementation:
en: 8e8d8e4f786e03c3
zh: 7b32d8cea29d2b1a
en: 534b344a98142d91
zh: 1291b721c8fead84
/deepseek-ai-dsh-llm-deepseek-account/further-exploration:
en: 30a69b9c66d89580
zh: fcc026a6373c1bee
@@ -36,8 +36,8 @@
en: b0f531baf29daf52
zh: c89837e35981c318
/deepseek-ai-dsh-llm-deepseek-account/model-experience/authenticated-model-requests/kv-cache-effect:
en: db6aea22ebba25e1
zh: caf4817596204df4
en: 2ecaa7bbbdd638e9
zh: bb067520885d877b
/deepseek-ai-dsh-llm-deepseek-account/known-limitations-and-deferred-work:
en: ee8b7ff23c806e18
zh: bd26832c8d455012
+3 -3
View File
@@ -33,7 +33,7 @@ The first access of a credential waits for the listing up to `modelsTimeoutMs`.
A listed model is offered only when the current DeepSeek Harness version can serve it. A model that declares `client_requirements["deepseek-harness"].min_version` is offered when the current version is at least that version in semantic-version order, including prerelease tags such as `0.2.0-alpha.1` and `0.2.0-rc.1`. A prerelease orders below its release: `min_version: 0.2.1` refuses `0.2.1-alpha.2`, so a listing that admits prereleases declares the earliest one it admits, such as `0.2.1-alpha.0`. A model without that declaration is offered only when the built-in catalog knows its id; other listed models stay hidden. A request for a listed model the current version cannot serve fails before network I/O with `CLIENT_UPGRADE_REQUIRED`, which names the required and current versions, or with `MODEL_UNSUPPORTED`. Model ids the listing does not name pass through unchanged.
Offered models take the listing's name, `context_window`, input modalities, and Messages system prompt mode over the bundled entry for the same id, which keeps its tool-update mode, image budgets, and description. `max_output_tokens` bounds the request default: a request that names no cap sends the smaller of `maxTokens` and that capability. The listing's effort levels are not read; every model offers `off`, `low`, `high`, and `max`.
Offered models take the listing's name, `context_window`, input modalities, and Messages system prompt and tool update modes (`api_capabilities.anthropic_messages.system_prompt_update` and `tool_update`) over the bundled entry for the same id, which keeps its image budgets and description. A mode the listing omits or sets to `null` keeps the bundled one; any value other than the modes this build reads (`in-history` for system prompts; `in-history` or `addition-only` for tools) reads as `leading-only`, which removes the bundled mode. `max_output_tokens` bounds the request default: a request that names no cap sends the smaller of `maxTokens` and that capability. The listing's effort levels are not read; every model offers `off`, `low`, `high`, and `max`.
`modelOverrides` adjusts single models without replacing the catalog. Keys are model ids; values take the fields of a [Messages `models` entry](../llm-deepseek/README.md#minimal-configuration), and unset fields inherit. Overrides apply over the listing and the bundled catalog; they cannot add models or make a refused model available. Listed capabilities do not bound an override: an override `maxTokens` above the listed `max_output_tokens` is sent as written, and the service rejects requests beyond its limits. An override that names a model neither the listing nor the built-in catalog describes, or that the listed model rejects, becomes the provider diagnostic on the Models page, which has no editor for this route. An override for a refused model that the built-in catalog does not describe is checked only once the current version can serve that model. The route has no `models` field; a configuration that still sets it fails at load and names `modelOverrides`. Edit `$DSH_HOME/profiles/<profile>/cordis.patch.yml`; the next request uses the change.
@@ -61,7 +61,7 @@ The settings namespace is the Cordis entry id, or the plugin name without an ent
<a id="understand-the-implementation"></a>
## Understand the implementation
Registrations and listeners dispose with the plugin, and disposal aborts an in-flight listing request. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route and uses the transport's `connect` and `admit` hooks for the catalog and the gate. The listing cache lives in memory and is keyed by endpoint and a SHA-256 of the token.
Registrations and listeners dispose with the plugin, and disposal aborts an in-flight listing request. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route and uses the transport's `connect` and `admit` hooks for the catalog and the gate. The listing request, its cache, and catalog resolution come from the shared transport, which the API-key route also uses; the cache lives in memory and is keyed by endpoint and a SHA-256 of the token.
A failed request the shared transport classifies as `QUOTA` is rewritten to `ACCOUNT_QUOTA` before it leaves this provider, covering an HTTP 402 response and an in-band SSE error alike. The shared transport and the `deepseek-official` route keep `QUOTA`, so the account route's top-up action never appears for an API-key or third-party failure.
@@ -85,7 +85,7 @@ Authentication and the listing add no input tokens. The resolved catalog sets ea
#### KV Cache effect
Credentials and catalog availability do not enter model input; the protocol transport owns request-prefix serialization. A catalog change to a model's system prompt mode changes how later system prompt updates are serialized.
Credentials and catalog availability do not enter model input; the protocol transport owns request-prefix serialization. A catalog change to a model's system prompt or tool update mode changes how later system prompt or tool updates are serialized.
## Known Limitations and Deferred Work
@@ -33,7 +33,7 @@ kind: "package-reference"
只有当前版本的 DeepSeek Harness 能用的模型才会出现在目录里。声明了 `client_requirements["deepseek-harness"].min_version` 的模型,在当前版本号按 semver 顺序不低于该版本时提供,预发布号(如 `0.2.0-alpha.1`、`0.2.0-rc.1`)同样参与比较。预发布版本低于同号的正式版本:`min_version: 0.2.1` 会拒绝 `0.2.1-alpha.2`,因此要放行预发布版本的列表应声明它允许的最早一个,如 `0.2.1-alpha.0`。没有这个声明的模型,只有内置目录认识它的 id 时才提供;其余列出的模型保持隐藏。请求一个当前版本不能用的已列出模型时,会在网络请求之前失败:`CLIENT_UPGRADE_REQUIRED` 会写明所需版本和当前版本,否则为 `MODEL_UNSUPPORTED`。列表没有列出的模型 id 照常透传。
提供的模型以列表中的名称、`context_window`、输入模态和 Messages system prompt 模式覆盖同 id 的内置条目,内置条目保留工具更新模式、图片预算和描述。`max_output_tokens` 限制请求默认值:请求没有指定上限时,发送 `maxTokens` 与该能力上限中较小的一个。列表中的推理档位不被读取;每个模型都提供 `off`、`low`、`high`、`max`。
提供的模型以列表中的名称、`context_window`、输入模态,以及 Messages 的 system prompt 模式和工具更新模式(`api_capabilities.anthropic_messages.system_prompt_update` 与 `tool_update`)覆盖同 id 的内置条目,内置条目保留图片预算和描述。列表没有给出或设为 `null` 的模式沿用内置值;本版本能读的模式之外的任何值(system prompt 能读 `in-history`,工具能读 `in-history` 或 `addition-only`)都按 `leading-only` 处理,即去掉内置的模式。`max_output_tokens` 限制请求默认值:请求没有指定上限时,发送 `maxTokens` 与该能力上限中较小的一个。列表中的推理档位不被读取;每个模型都提供 `off`、`low`、`high`、`max`。
`modelOverrides` 调整单个模型而不替换整个目录。键是模型 id;值的字段与 [Messages `models` 条目](../llm-deepseek/README.zh.md#minimal-configuration)相同,未设置的字段沿用下层的值。覆盖作用在列表和内置目录之上;不能新增模型,也不能让被拒绝的模型变得可用。列表声明的能力不约束覆盖:覆盖的 `maxTokens` 高于列表的 `max_output_tokens` 时按覆盖值发送,超出服务端限制的请求由服务端拒绝。覆盖指向的模型既不在列表里、内置目录也不认识,或者被列出的模型拒绝时,会成为模型页上该提供方的诊断信息;模型页不为此路由提供编辑器。被拒绝且内置目录不认识的模型,其覆盖要等当前版本能用该模型时才校验。此路由没有 `models` 字段;配置里仍写了它时,加载即失败并提示改用 `modelOverrides`。修改 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`,下一次请求即生效。
@@ -61,7 +61,7 @@ kind: "package-reference"
<a id="understand-the-implementation"></a>
## 理解实现
注册和监听随插件生命周期释放,释放时会中止进行中的列表请求。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由,并通过传输层的 `connect` 与 `admit` 钩子接入目录和门控。列表缓存只在内存中,以端点和 token 的 SHA-256 为键。
注册和监听随插件生命周期释放,释放时会中止进行中的列表请求。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由,并通过传输层的 `connect` 与 `admit` 钩子接入目录和门控。列表请求、缓存和目录解析来自共享传输包,API key 路由也使用它们;缓存只在内存中,以端点和 token 的 SHA-256 为键。
共享传输判定为 `QUOTA` 的失败请求离开本提供方前会被改写为 `ACCOUNT_QUOTA`,HTTP 402 响应与流内 SSE 错误都适用。共享传输与 `deepseek-official` 路由保留 `QUOTA`,因此账号路由的充值操作不会出现在 API Key 或第三方失败上。
@@ -85,7 +85,7 @@ kind: "package-reference"
#### KV Cache effect
凭据与目录可用性不进入模型输入;协议传输拥有请求前缀的序列化。目录中某个模型的 system prompt 模式变化,会改变之后 system prompt 更新的序列化方式。
凭据与目录可用性不进入模型输入;协议传输拥有请求前缀的序列化。目录中某个模型的 system prompt 模式或工具更新模式变化,会改变之后 system prompt 更新或工具更新的序列化方式。
## 已知限制与后续工作
@@ -30,9 +30,7 @@
"license": "MIT",
"dependencies": {
"@deepseek-ai/cosmokit": "workspace:~",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"semver": "^7.8.5"
"@deepseek-ai/schemastery": "workspace:~"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -40,8 +38,7 @@
"@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-timeout": "workspace:*"
"@deepseek-ai/cordis-plugin-loader": "workspace:~"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -51,8 +48,7 @@
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*",
"@deepseek-ai/dsh-timeout": "workspace:*",
"@types/semver": "^7.8.0"
"@deepseek-ai/dsh-timeout": "workspace:*"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-deepseek-account": {
+14 -51
View File
@@ -2,28 +2,17 @@
import type { Volatile } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { isVolatile } from '@deepseek-ai/cosmokit'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { catalogModelFields, deepSeekConfigFields, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import type { Config as ProtocolConfig, DeepSeekCatalogModel, Options as ProtocolOptions, ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek'
/** Default deadline of one model-listing request and of a first-use wait. */
export const DEFAULT_MODELS_TIMEOUT_MS = 5_000
/** Default listing age after which the next access refreshes it in the background. */
export const DEFAULT_MODELS_MAX_AGE_MS = 600_000
/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */
export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
import {
catalogModelFields, deepSeekConfigFields, modelListingConfigFields, modelListingURL, resolveAdapterOptions, resolveListingTiming,
} from '@deepseek-ai/dsh-llm-deepseek'
import type {
Config as ProtocolConfig, ListingSettings, ModelListingConfig, ModelListingOptions, ModelOverride, Options as ProtocolOptions,
ResolvedDeepSeekOptions,
} from '@deepseek-ai/dsh-llm-deepseek'
/** Account route configuration; authentication comes exclusively from the account service. */
export interface Config extends Omit<ProtocolConfig, 'models'> {
/** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */
modelsTimeoutMs: Volatile<number>
/**
* Listing age after which the next access refreshes it in the background;
* a failed load retries after the same age (default ten minutes).
*/
modelsMaxAgeMs: Volatile<number>
export interface Config extends Omit<ProtocolConfig, 'models'>, ModelListingConfig {
/** Field overrides by model id, layered over the account listing and the built-in catalog. */
modelOverrides: Volatile<Record<string, ModelOverride>>
}
@@ -38,34 +27,21 @@ const modelOverride: z<ModelOverride> = z.object({
export const Config = z.object({
...protocolFields,
modelsTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_MODELS_TIMEOUT_MS).volatile(),
modelsMaxAgeMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MODELS_MAX_AGE_MS).volatile(),
...modelListingConfigFields,
modelOverrides: z.dict(modelOverride).default({}).volatile(),
})
/** Plain account options accepted by {@link resolveAccountOptions}. */
export type AccountOptions = Omit<ProtocolOptions, 'models'> & {
modelsTimeoutMs?: number
modelsMaxAgeMs?: number
export type AccountOptions = Omit<ProtocolOptions, 'models'> & ModelListingOptions & {
modelOverrides?: Record<string, ModelOverride>
}
/** Model-listing endpoint and cache timing of one configuration generation. */
export interface CatalogSettings {
/** Listing endpoint; it always shares the Messages origin. */
readonly url: string
/** Deadline of one listing request. */
readonly timeoutMs: number
/** Listing age after which the next access refreshes it. */
readonly maxAgeMs: number
}
/** One validated configuration generation of the account route. */
export interface ResolvedAccountOptions {
/** Messages settings; request resolution replaces `models` with the resolved account catalog. */
readonly protocol: ResolvedDeepSeekOptions
/** Listing endpoint and cache timing. */
readonly catalog: CatalogSettings
readonly catalog: ListingSettings
/** Overrides by model id, holding only the fields each one declares. */
readonly modelOverrides: Readonly<Record<string, ModelOverride>>
}
@@ -79,11 +55,6 @@ export function plainAccountOptions(config: Config): AccountOptions {
return Object.fromEntries(Object.entries(config).map(([key, value]) => [key, isVolatile(value) ? value.get() : value]))
}
/** The listing endpoint: `/models` at the Messages origin, the only origin that receives the account token. */
function listingEndpoint(baseURL: string): string {
return new URL('/models', new URL(baseURL).origin).href
}
/** Keep an override's declared fields: parsed configuration carries unset fields as `undefined` and unset arrays as `[]`. */
function resolveOverride(id: string, override: Readonly<Record<string, unknown>>): ModelOverride {
if (id.length === 0) throw new Error('llm-deepseek-account: modelOverrides keys must be non-empty model ids')
@@ -115,20 +86,12 @@ export function resolveAccountOptions(options: AccountOptions, environment?: Lau
throw new Error('llm-deepseek-account: models is no longer configurable; the account /models listing supplies the catalog. '
+ 'Remove models from this entry and adjust single models with modelOverrides.<model id>')
}
const {
modelsTimeoutMs = DEFAULT_MODELS_TIMEOUT_MS, modelsMaxAgeMs = DEFAULT_MODELS_MAX_AGE_MS,
modelOverrides = {}, ...protocolOptions
} = options
if (!Number.isFinite(modelsTimeoutMs) || modelsTimeoutMs <= 0 || modelsTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(`llm-deepseek-account: modelsTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
if (!Number.isSafeInteger(modelsMaxAgeMs) || modelsMaxAgeMs < 0) {
throw new Error('llm-deepseek-account: modelsMaxAgeMs must be a non-negative safe integer')
}
const { modelsTimeoutMs: _timeout, modelsMaxAgeMs: _maxAge, modelOverrides = {}, ...protocolOptions } = options
const timing = resolveListingTiming(options, 'llm-deepseek-account')
const protocol = resolveAdapterOptions(protocolOptions, environment)
return {
protocol,
catalog: { url: listingEndpoint(protocol.baseURL), timeoutMs: modelsTimeoutMs, maxAgeMs: modelsMaxAgeMs },
catalog: { url: modelListingURL(protocol.baseURL), ...timing },
modelOverrides: Object.fromEntries(Object.entries(modelOverrides).map(([id, override]) => [id, resolveOverride(id, override)])),
}
}
+15 -36
View File
@@ -4,16 +4,12 @@ import type { Context } from '@deepseek-ai/cordis'
import { ACCOUNT_QUOTA_EXCEEDED_CODE, APP_IDENTITY, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-deepseek-account'
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { catalogModelInfo, registerDeepSeekProvider, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import type { DeepSeekRequestAuth, ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { AccountModelCatalog } from './catalog.ts'
import type { CatalogView } from './catalog.ts'
import {
catalogModelInfo, ModelListingCache, refuseListedModel, registerDeepSeekProvider, requestModelListing, resolveAdapterOptions,
resolveListedCatalog,
} from '@deepseek-ai/dsh-llm-deepseek'
import type { DeepSeekRequestAuth, ListedCatalog, ListingView, ModelListing, ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { Config, plainAccountOptions, resolveAccountOptions } from './config.ts'
import { requestModelListing } from './listing.ts'
import type { ModelListing } from './listing.ts'
import { resolveAccountCatalog } from './resolve-catalog.ts'
import type { AccountCatalog, CatalogRefusal } from './resolve-catalog.ts'
export { Config } from './config.ts'
export const name = 'llm-deepseek-account'
@@ -27,23 +23,7 @@ const LOG_TAG = '[llm-deepseek-account]'
/** Connection facts of one account operation: protocol settings plus the catalog resolved with them. */
interface AccountConnection extends ResolvedDeepSeekOptions {
readonly catalog: AccountCatalog
}
function refuse(model: string, refusal: CatalogRefusal | undefined): void {
if (refusal === undefined) return
switch (refusal.kind) {
case 'upgrade-required':
throw new LlmError(
`DeepSeek model "${model}" requires DeepSeek Harness ${refusal.requiredVersion} or later; current version is ${APP_IDENTITY.version}`,
'CLIENT_UPGRADE_REQUIRED',
)
case 'unsupported':
throw new LlmError(`DeepSeek model "${model}" is not supported by DeepSeek Harness; current version is ${APP_IDENTITY.version}`, 'MODEL_UNSUPPORTED')
/* v8 ignore next 2 -- catalog resolution produces only the closed refusal union */
default:
assertNever(refusal, 'account catalog refusal')
}
readonly catalog: ListedCatalog
}
export function apply(ctx: Context, config: Config): void {
@@ -70,9 +50,8 @@ export function apply(ctx: Context, config: Config): void {
},
}
}
const catalog = new AccountModelCatalog({
settings: () => settings().catalog,
token: async url => account()?.resolveToken(url),
const catalog = new ModelListingCache({
credential: async url => account()?.resolveToken(url),
load: (url, token, signal) => requestModelListing(url, { [TOKEN_HEADER]: token }, signal),
now: () => Date.now(),
info: (message) => { console.info(LOG_TAG, message) },
@@ -80,9 +59,9 @@ export function apply(ctx: Context, config: Config): void {
})
let loggedListing: ModelListing | undefined
/** Resolve the catalog for one view, logging the listing's hidden entries once per listing. */
const connectionOf = (view: CatalogView): AccountConnection => {
const connectionOf = (view: ListingView): AccountConnection => {
const resolved = settings()
const result = resolveAccountCatalog({
const result = resolveListedCatalog({
listing: view.listing?.entries,
supported,
overrides: resolved.modelOverrides,
@@ -99,7 +78,7 @@ export function apply(ctx: Context, config: Config): void {
const directory = ctx.llm.registerConfigurableProviders([entry])
let reportedError: string | undefined
/** Publish override diagnostics on the provider directory. */
const discovered = (view: CatalogView): AccountConnection => {
const discovered = (view: ListingView): AccountConnection => {
const connection = connectionOf(view)
const error = connection.catalog.overrideErrors.length === 0 ? undefined : connection.catalog.overrideErrors.join('; ')
if (error !== reportedError) {
@@ -111,12 +90,12 @@ export function apply(ctx: Context, config: Config): void {
registerDeepSeekProvider<AccountConnection>(ctx, PROVIDER, {
providerName: 'DeepSeek Account',
options: () => connectionOf(catalog.current()),
connect: async signal => connectionOf(await catalog.view(signal)),
connect: async signal => connectionOf(await catalog.view(settings().catalog, signal)),
resolveAuth,
admit: (connection, model) => { refuse(model, connection.catalog.refusals.get(model)) },
admit: (connection, model) => { refuseListedModel(model, connection.catalog.refusals.get(model)) },
discoverModels: async (provider) => {
const view = await catalog.view()
return view.signedIn ? discovered(view).catalog.offered.map(model => catalogModelInfo(provider, model)) : []
const view = await catalog.view(settings().catalog)
return view.authenticated ? discovered(view).catalog.offered.map(model => catalogModelInfo(provider, model)) : []
},
})
ctx.effect(() => () => { catalog.dispose() }, 'llm-deepseek-account: model catalog')
@@ -13,7 +13,7 @@ afterEach(async () => {
vi.unstubAllEnvs()
})
/** Fail the account listing request fast, so the route serves its bundled catalog and each call gets a fresh Messages response. */
/** Fail the model listing request fast, so either route serves its bundled catalog and each call gets a fresh Messages response. */
function messagesOnly(response: () => Response) {
return (input: string | URL | Request): Promise<Response> => {
const url = new URL(input instanceof Request ? input.url : input)
@@ -105,7 +105,7 @@ it('falls back to the bundled catalog when the listing fails', async () => {
await ctx.plugin(Account, { baseURL: server.baseURL })
expect((await ctx.llm.listModels('deepseek-account')).map(row => row.id)).toEqual(['deepseek-flash', 'deepseek-v4-pro'])
expect(warn).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-account]', expect.stringContaining('using the bundled catalog') as string)
await expect(ctx.llm.resolveModelInfo('deepseek-account', 'deepseek-flash')).resolves.toMatchObject({ context: { contextWindow: 1_000_000 } })
await expect(ctx.llm.resolveModelInfo('deepseek-account', 'deepseek-flash')).resolves.toMatchObject({ context: { contextWindow: 1_048_576 } })
})
it('hides and refuses listed models this build cannot serve before any inference request', async () => {
@@ -31,12 +31,6 @@
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/values"
},
{
"path": "../../util/timeout"
}
]
}
@@ -3,26 +3,26 @@
# 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
/:
en: 3bc991d01c87c9c6
zh: ba63ebb46b72bc3e
en: d0825db7a1a9d8ee
zh: d0825db7a1a9d8ee
/deepseek-ai-dsh-llm-deepseek-api-key:
en: b6871195e386cc64
zh: 742b05a3ca80883c
/deepseek-ai-dsh-llm-deepseek-api-key/summary:
en: ad5a00f16f233d42
zh: 743d55599144836b
en: f937052cf8ff4812
zh: 899d61f5021136d6
/deepseek-ai-dsh-llm-deepseek-api-key/table-of-contents:
en: 23386452a66b2fbd
zh: 4cf93c4bf17b3da2
/deepseek-ai-dsh-llm-deepseek-api-key/use-this-package:
en: 10333177c9033b9e
zh: 069faa6a125cb6a9
en: 2aad8a660b90561d
zh: 3f425b3d1ea023e1
/deepseek-ai-dsh-llm-deepseek-api-key/understand-the-implementation:
en: 002ac925beb4e75b
zh: 815d06f116604a09
en: d614c1ea3a9eb382
zh: 5ca070349bb22d56
/deepseek-ai-dsh-llm-deepseek-api-key/further-exploration:
en: e8bf99d78aa7035f
zh: e4a74d2d48b6d271
en: 347659458510940c
zh: ccfb03e18b356a39
/deepseek-ai-dsh-llm-deepseek-api-key/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
@@ -33,14 +33,14 @@
en: e5624a2c7bb26e90
zh: 44da0286a548f24a
/deepseek-ai-dsh-llm-deepseek-api-key/model-experience/authenticated-model-requests/token-effect:
en: fecefb75b719c317
zh: f0c2322f657c5914
en: 9dd6430752202069
zh: 53068bf7f828156f
/deepseek-ai-dsh-llm-deepseek-api-key/model-experience/authenticated-model-requests/kv-cache-effect:
en: 2004df60d997da79
zh: a215a6586b44746b
en: 8226f9d956013ad9
zh: bace9524b7acc1fa
/deepseek-ai-dsh-llm-deepseek-api-key/known-limitations-and-deferred-work:
en: d5880c883b1b678f
zh: bf7a4da24ba75280
en: e0a41598c126c187
zh: 857ab65c27f41612
/deepseek-ai-dsh-llm-deepseek-api-key/known-limitations-and-deferred-work/dev-note:
en: 0525041e573e96b6
zh: b05ed8d852498e6b
+27 -8
View File
@@ -1,5 +1,5 @@
---
description: "DeepSeek api-key authentication and model discovery."
description: "DeepSeek API-key authentication, model discovery, and the official model listing."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ 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.
Register authentication, model discovery, and the official model listing for `deepseek-official`. This plugin shares the [Messages transport](../llm-deepseek/README.md) and owns credential resolution, catalog availability, and model admission.
Authentication resolution returns the validated API key in `x-api-key` for both Messages and Files requests.
@@ -37,17 +37,30 @@ Authentication resolution returns the validated API key in `x-api-key` for both
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.
`models` is an independently configurable catalog for this provider; defaults and protocol capabilities come from the shared transport. While `models` equals the built-in catalog and the Messages origin is the official API, `https://api.deepseek.com`, the route serves the official `GET https://api.deepseek.com/models` listing instead. Listing requests carry the API key in `x-api-key` and the harness `User-Agent`, and reject redirects; without an API key the route requests nothing. Any other endpoint, including one named by `baseURL` or `$DEEPSEEK_BASE_URL`, serves `models` and requests no listing. Discovery does not probe inference endpoints.
The first access waits for the listing up to `modelsTimeoutMs`. Later accesses use the loaded listing and refresh it in the background once it is older than `modelsMaxAgeMs`; an idle route sends no requests. A failed or empty listing keeps the previous one; before any listing loads, the route serves the built-in catalog of the current version (`deepseek-flash` and `deepseek-v4-pro`). A listing HTTP 401 fails only that load. Each loaded listing, the first failure of a failing streak with the catalog it falls back to, and each listing entry hidden because its declarations are unreadable or unusable are written to stderr with the `[llm-deepseek-api-key]` tag; stdout remains available for protocol messages, and secrets are never logged.
Listed models are offered, merged with this build's entries, and refused before network I/O with `CLIENT_UPGRADE_REQUIRED` or `MODEL_UNSUPPORTED` by the [account route's rules](../llm-deepseek-account/README.md#use-this-package); model ids the listing does not name pass through unchanged.
A `models` list that differs from the built-in catalog replaces the catalog and stops listing requests; the Models page stores an edited catalog there. Removing that list, as **Restore defaults** on the Models page does, returns the route to the listing, and the next access loads the listing again even when the cached one is still fresh. A list identical to the built-in catalog counts as uncustomized.
| Field | Default | Meaning |
|---|---|---|
| `modelsTimeoutMs` | `5,000` | Deadline of one listing request and of a first-access wait |
| `modelsMaxAgeMs` | `600,000` | Listing age after which the next access refreshes it; a failed load retries after the same age |
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.
<a id="understand-the-implementation"></a>
## 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.
Registrations and listeners dispose with the plugin, and disposal aborts an in-flight listing request. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route and uses the transport's `connect` and `admit` hooks for the listed catalog and the gate. The listing request, its cache, and catalog resolution come from the shared transport; the cache lives in memory and is keyed by endpoint and a SHA-256 of the key, so another key loads its own listing. A `loader/volatile-update` listener discards the cached listing whenever the route starts or stops following it.
<a id="further-exploration"></a>
## Further Exploration
[LLM streaming](../../../docs/subsystems/llm-streaming.md) · [Messages](../llm-deepseek/README.md)
[LLM streaming](../../../docs/subsystems/llm-streaming.md) · [Messages](../llm-deepseek/README.md) · [Official listing decision](../../../.agents/notes/implemented/architecture/2026-10-22-deepseek-official-model-listing.md)
<a id="model-experience"></a>
## Model Experience
@@ -60,17 +73,23 @@ Requests on `deepseek-official` add no model-visible text from this plugin; the
#### Token effect
Authentication and discovery add no input tokens; the selected model and request content determine actual usage.
Authentication, discovery, and the listing add no input tokens. The followed listing sets each model's context window and default output cap, which each request's logged context and header record.
#### KV Cache effect
Credentials and catalog availability do not enter model input; the protocol transport owns request-prefix serialization.
Credentials and catalog availability do not enter model input; the protocol transport owns request-prefix serialization. A listing change to a model's system prompt or tool update mode changes how later system prompt or tool updates are serialized.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- The catalog is configuration metadata, not remote validation that a model is accepted; actual calls can still fail.
- A configured catalog is configuration metadata, not remote validation that a model is accepted; actual calls can still fail.
- The Models page shows the built-in catalog while the route follows the listing, so a model only the listing offers appears only in model selectors. Editing that catalog on the page stores the rows the page shows, which drops such a model until **Restore defaults**.
- A customized catalog does not receive models the listing adds later.
- Customization is judged against the running version's built-in catalog, so a saved `models` list equal to an earlier version's built-in catalog counts as customized once the built-in catalog changes and stops following the listing until **Restore defaults**.
- The listing cache lives in memory; a start without network serves the built-in catalog until a listing loads.
- A listing change during a running process reaches the Web model picker at its next catalog refresh; the plugin does not announce it.
- When the current version can serve no listed model, the route offers no models.
<a id="dev-note"></a>
### Dev Note
+27 -8
View File
@@ -1,5 +1,5 @@
---
description: "DeepSeek API key authentication and model discovery."
description: "DeepSeek API-key authentication, model discovery, and the official model listing."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
为 `deepseek-official` 注册独立的鉴权和模型发现插件。通过 [llm-deepseek](../llm-deepseek/README.zh.md) 共用 Messages 请求实现;本包拥有凭据解析和目录可用性判断。
为 `deepseek-official` 注册鉴权、模型发现和官方模型列表。通过 [llm-deepseek](../llm-deepseek/README.zh.md) 共用 Messages 请求实现;本包拥有凭据解析、目录可用性判断和模型准入。
鉴权解析将验证后的 API key 放入 `x-api-key`,供 Messages 和 Files 请求共同使用。
@@ -37,17 +37,30 @@ kind: "package-reference"
端点和凭据引用来自同一次配置解析;进行中的请求保留该快照,后续配置更新仅影响后续请求。账号登录态不影响该路由使用的凭据。
`models` 是该 provider 独立的可配置目录;默认值和协议能力来自共享传输包。目录判断不探测推理端点。设置命名空间采用 Cordis entry id,没有 entry 时采用插件名。产品保留 official 的 `llm-deepseek` entry id,账号使用 `llm-deepseek-account`。
`models` 是该 provider 独立的可配置目录;默认值和协议能力来自共享传输包。当 `models` 等于内置目录、且 Messages 的 origin 是官方 API `https://api.deepseek.com` 时,路由改为提供官方 `GET https://api.deepseek.com/models` 列表。列表请求用 `x-api-key` 携带 API key,带上 harness 的 `User-Agent`,并拒绝重定向;没有 API key 时不发任何请求。其他端点,包括由 `baseURL` 或 `$DEEPSEEK_BASE_URL` 指定的端点,提供 `models`,不请求列表。目录判断不探测推理端点。
第一次访问会等待列表,最长 `modelsTimeoutMs`。之后的访问直接使用已加载的列表,列表超过 `modelsMaxAgeMs` 后在后台刷新;路由空闲时不发请求。获取失败或得到空列表时保留上一份;还没有加载到任何列表时,路由提供当前版本内置的目录(`deepseek-flash` 与 `deepseek-v4-pro`)。列表请求返回 HTTP 401 只让这一次加载失败。每次加载成功、连续失败中的第一次失败及其退回的目录、以及每个因声明无法读取或不可用而被隐藏的列表条目,都会以 `[llm-deepseek-api-key]` 标签写到 stderr;stdout 保留给协议消息,且不记录任何密钥。
列出的模型按[账号路由的规则](../llm-deepseek-account/README.zh.md#use-this-package)提供、与本版本的内置条目合并,并在网络请求之前以 `CLIENT_UPGRADE_REQUIRED` 或 `MODEL_UNSUPPORTED` 拒绝;列表没有列出的模型 id 照常透传。
与内置目录不同的 `models` 列表会替换整个目录,并停止请求列表;模型页把编辑后的目录存在这里。删除这个列表(即模型页上的**恢复默认模型**)后,路由重新使用列表,即使缓存的列表尚未过期,下一次访问也会重新加载。与内置目录完全相同的列表视为未自定义。
| 字段 | 默认值 | 含义 |
|---|---|---|
| `modelsTimeoutMs` | `5,000` | 单次列表请求以及首次访问等待的上限 |
| `modelsMaxAgeMs` | `600,000` | 列表超过这个时长后,下一次访问时刷新;获取失败后同样在这个时长后重试 |
设置命名空间采用 Cordis entry id,没有 entry 时采用插件名。产品保留 official 的 `llm-deepseek` entry id,账号使用 `llm-deepseek-account`。
<a id="understand-the-implementation"></a>
## 理解实现
注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。
注册和监听随插件生命周期释放,释放时会中止进行中的列表请求。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由,并通过传输层的 `connect` 与 `admit` 钩子接入列表目录和门控。列表请求、缓存和目录解析来自共享传输包;缓存只在内存中,以端点和 API key 的 SHA-256 为键,因此换一个 key 会加载它自己的列表。一个 `loader/volatile-update` 监听器在路由开始或停止使用列表时丢弃缓存的列表。
<a id="further-exploration"></a>
## 进一步探索
[LLM streaming](../../../docs/subsystems/llm-streaming.zh.md) · [Messages](../llm-deepseek/README.zh.md)
[LLM streaming](../../../docs/subsystems/llm-streaming.zh.md) · [Messages](../llm-deepseek/README.zh.md) · [官方列表决策](../../../.agents/notes/implemented/architecture/2026-10-22-deepseek-official-model-listing.zh.md)
<a id="model-experience"></a>
## 模型体验
@@ -60,17 +73,23 @@ kind: "package-reference"
#### Token effect
鉴权和目录过滤不增加输入 token;实际调用的 token 由所选模型与请求内容决定。
鉴权、目录过滤和列表都不增加输入 token。所使用的列表决定每个模型的上下文窗口和默认输出上限,每次请求记录的上下文和请求头会写明这些值。
#### KV Cache effect
凭据与目录可用性不进入模型输入;协议传输拥有请求前缀的序列化。
凭据与目录可用性不进入模型输入;协议传输拥有请求前缀的序列化。列表改变某个模型的 system prompt 模式或工具更新模式时,之后的 system prompt 更新或工具更新的序列化方式随之改变。
## 已知限制与后续工作
<a id="known-limitations-and-deferred-work"></a>
- 目录是配置元数据,不验证远端是否接受模型;实际调用仍可能失败。
- 配置的目录是配置元数据,不验证远端是否接受模型;实际调用仍可能失败。
- 路由使用列表时,模型页显示的仍是内置目录,因此只有列表提供的模型只出现在模型选择器中。在模型页编辑目录会保存页面上显示的行,这类模型会消失,直到点**恢复默认模型**。
- 自定义的目录收不到列表之后新增的模型。
- 是否自定义按当前版本的内置目录判断:保存的 `models` 列表若等于旧版本的内置目录,内置目录变化后就算作自定义,不再跟随列表,直到点**恢复默认模型**。
- 列表缓存只在内存中;没有网络的启动会一直提供内置目录,直到加载到列表。
- 进程运行期间的列表变化在 Web 模型选择器下一次刷新目录时生效;插件不主动通知。
- 当前版本不能用任何已列出的模型时,路由不提供模型。
<a id="dev-note"></a>
### 开发备注
@@ -1,26 +1,40 @@
/** API-key configuration resolved together with one Messages endpoint generation. */
/** API-key configuration resolved together with one Messages endpoint generation and its model listing. */
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'
import {
deepSeekConfigFields, isBuiltInCatalog, modelListingConfigFields, modelListingURL, plainOptions as protocolOptions, PUBLIC_BASE_URL,
resolveAdapterOptions as resolveProtocolOptions, resolveListingTiming,
} from '@deepseek-ai/dsh-llm-deepseek'
import type {
Config as ProtocolConfig, DeepSeekConnectionOptions, ListingSettings, ModelListingConfig, ModelListingOptions, Options as ProtocolOptions,
} from '@deepseek-ai/dsh-llm-deepseek'
/** Origin of the official API, the only endpoint whose `/models` listing this route follows. */
const OFFICIAL_ORIGIN = new URL(PUBLIC_BASE_URL).origin
/** Messages configuration with a per-request API-key reference. */
export interface Config extends ProtocolConfig {
export interface Config extends ProtocolConfig, ModelListingConfig {
/** Credential reference resolved per request; defaults to DEEPSEEK_API_KEY. */
apiKeyEnv: Volatile<string>
}
export const Config = z.object({
...deepSeekConfigFields,
...modelListingConfigFields,
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 }
export type Options = ProtocolOptions & ModelListingOptions & { 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
/**
* The official `/models` listing this generation follows; absent when `models`
* differs from the built-in catalog or the endpoint is not the official API.
*/
listing?: ListingSettings
}
/** Read one validated provider configuration.
* @param config - live plugin configuration.
@@ -29,11 +43,19 @@ export interface ResolvedDeepSeekOptions extends DeepSeekConnectionOptions {
export function plainOptions(config: Config): Options {
return { ...protocolOptions(config), apiKeyEnv: config.apiKeyEnv.get() }
}
/** Resolve API-key and protocol settings together.
/** Resolve API-key, listing, and protocol settings together.
* @param config - raw deployment settings.
* @param environment - application launch environment.
* @returns validated endpoint facts and the matching credential reference.
* @returns validated endpoint facts, the matching credential reference, and the listing the catalog follows.
*/
export function resolveAdapterOptions(config: Options, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions {
return { ...resolveProtocolOptions(config, environment), apiKeyEnv: credentialRef(config.apiKeyEnv ?? 'DEEPSEEK_API_KEY') }
const { modelsTimeoutMs: _timeout, modelsMaxAgeMs: _maxAge, apiKeyEnv, ...messages } = config
const timing = resolveListingTiming(config, 'llm-deepseek-api-key')
const connection = resolveProtocolOptions(messages, environment)
const listed = new URL(connection.baseURL).origin === OFFICIAL_ORIGIN && isBuiltInCatalog(connection.models)
return {
...connection,
apiKeyEnv: credentialRef(apiKeyEnv ?? 'DEEPSEEK_API_KEY'),
...listed ? { listing: { url: modelListingURL(connection.baseURL), ...timing } } : {},
}
}
+86 -10
View File
@@ -1,9 +1,12 @@
/** API-key authentication and credential-gated discovery for the official DeepSeek route. */
/** API-key authentication, credential-gated discovery, and the official model listing for the official DeepSeek route. */
import type { Context } from '@deepseek-ai/cordis'
import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm'
import { APP_IDENTITY, 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 {
catalogModelInfo, ModelListingCache, refuseListedModel, registerDeepSeekProvider, requestModelListing, resolveListedCatalog,
} from '@deepseek-ai/dsh-llm-deepseek'
import type { CatalogRefusal, DeepSeekCatalogModel, ListingView, ModelListing } from '@deepseek-ai/dsh-llm-deepseek'
import { Config, plainOptions, resolveAdapterOptions } from './config.ts'
import type { ResolvedDeepSeekOptions } from './config.ts'
@@ -13,6 +16,16 @@ export const name = 'llm-deepseek-api-key'
export const inject = ['llm']
const PROVIDER = 'deepseek-official'
/** Console tag of listing diagnostics; product profiles mount no console exporter for `ctx.logger`. */
const LOG_TAG = '[llm-deepseek-api-key]'
/** Connection facts of one operation: protocol settings plus the catalog resolved with them. */
interface OfficialConnection extends ResolvedDeepSeekOptions {
/** Selectable models: the configured catalog, or the offered part of the followed listing. */
readonly offered: readonly DeepSeekCatalogModel[]
/** Listed models refused before network I/O, by id. */
readonly refusals: ReadonlyMap<string, CatalogRefusal>
}
export function apply(ctx: Context, config: Config): void {
const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx))
@@ -33,20 +46,83 @@ export function apply(ctx: Context, config: Config): void {
'MISSING_CREDENTIAL',
)
}
/** The API key, or `undefined` when none is configured; other credential failures propagate. */
const presentApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string | undefined> => {
try { return await resolveApiKey(connection) }
catch (error) {
if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return undefined
throw error
}
}
const listings = new ModelListingCache({
credential: async () => presentApiKey(options()),
load: (url, apiKey, signal) => requestModelListing(url, { 'x-api-key': apiKey }, signal),
now: () => Date.now(),
// ACP reserves stdout for protocol messages, including during successful catalog loads.
info: (message) => { console.error(LOG_TAG, message) },
warn: (message) => { console.warn(LOG_TAG, message) },
})
let reportedListing: ModelListing | undefined
/** Resolve the catalog of one operation, reporting each listing's hidden entries once. */
const connectionOf = (connection: ResolvedDeepSeekOptions, view: ListingView | undefined): OfficialConnection => {
if (view === undefined) return { ...connection, offered: connection.models, refusals: new Map() }
const catalog = resolveListedCatalog({
listing: view.listing?.entries,
supported: connection.models,
overrides: {},
routeMaxTokens: connection.maxTokens,
clientVersion: APP_IDENTITY.version,
})
if (view.listing !== reportedListing) {
reportedListing = view.listing
for (const problem of catalog.problems) console.warn(LOG_TAG, problem)
}
return { ...connection, models: catalog.resolution, offered: catalog.offered, refusals: catalog.refusals }
}
ctx.llm.registerConfigurableProviders([
{ provider: PROVIDER, displayName: 'DeepSeek', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] },
])
registerDeepSeekProvider(ctx, PROVIDER, {
options, providerName: 'DeepSeek',
registerDeepSeekProvider<OfficialConnection>(ctx, PROVIDER, {
providerName: 'DeepSeek',
options: () => {
const connection = options()
return connectionOf(connection, connection.listing === undefined ? undefined : listings.current())
},
connect: async (signal) => {
const connection = options()
if (connection.listing === undefined) return connectionOf(connection, undefined)
let view: ListingView
try { view = await listings.view(connection.listing, signal) }
catch (error) {
if (signal?.aborted === true) throw error
// The cache keeps the previous credential's listing when this lookup fails, so gate nothing;
// `resolveAuth` reports the failure on the model request, after the turn records the prompt.
return connectionOf(connection, undefined)
}
return connectionOf(connection, view)
},
resolveAuth: async connection => ({ headers: { 'x-api-key': await resolveApiKey(connection) } }),
admit: (connection, model) => { refuseListedModel(model, connection.refusals.get(model)) },
discoverModels: async (provider) => {
const connection = options()
try { await resolveApiKey(connection) }
catch (error) {
if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return []
throw error
if (connection.listing === undefined) {
if (await presentApiKey(connection) === undefined) return []
return connection.models.map(model => catalogModelInfo(provider, model))
}
return connection.models.map(model => catalogModelInfo(provider, model))
const view = await listings.view(connection.listing)
return view.authenticated ? connectionOf(connection, view).offered.map(model => catalogModelInfo(provider, model)) : []
},
})
// Starting or stopping to follow the listing discards it, so restoring the built-in
// catalog after a customization reloads the listing on the next access.
let following = options().listing !== undefined
ctx.on('loader/volatile-update', () => {
let next: boolean
// The transport's own listener reports a configuration the adapter rejects.
try { next = options().listing !== undefined }
catch (_rejectedConfiguration) { return }
if (next !== following) listings.invalidate()
following = next
})
ctx.effect(() => () => { listings.dispose() }, 'llm-deepseek-api-key: model listing')
}
@@ -0,0 +1,30 @@
/** The API-key route follows the official listing only for the built-in catalog on the official endpoint. */
import { expect, it } from 'vitest'
import { resolveAdapterOptions as resolveProtocolOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { Config, plainOptions, resolveAdapterOptions } from '../src/config.ts'
const LISTING = { url: 'https://api.deepseek.com/models', timeoutMs: 5_000, maxAgeMs: 600_000 }
it('follows the official listing while the catalog is the built-in one', () => {
expect(resolveAdapterOptions({}).listing).toEqual(LISTING)
expect(resolveAdapterOptions(plainOptions(Config({}))).listing).toEqual(LISTING)
expect(resolveAdapterOptions({ models: [...resolveProtocolOptions({}).models] }).listing).toEqual(LISTING)
expect(resolveAdapterOptions({ baseURL: 'https://api.deepseek.com/anthropic/v1', modelsTimeoutMs: 7, modelsMaxAgeMs: 0 }).listing)
.toEqual({ url: 'https://api.deepseek.com/models', timeoutMs: 7, maxAgeMs: 0 })
})
it.each([
[{ models: [{ id: 'custom' }] }],
[{ models: [] }],
[{ baseURL: 'https://gateway.example/anthropic' }],
[{ baseURL: 'http://api.deepseek.com/anthropic' }],
])('keeps its own catalog for %j', (options) => {
expect(resolveAdapterOptions(options)).not.toHaveProperty('listing')
})
it.each([
[{ modelsTimeoutMs: 0 }, /^llm-deepseek-api-key: modelsTimeoutMs must be a positive finite number/],
[{ modelsMaxAgeMs: 1.5 }, /^llm-deepseek-api-key: modelsMaxAgeMs must be a non-negative safe integer/],
])('rejects %j', (options, message) => {
expect(() => resolveAdapterOptions({ models: [{ id: 'custom' }], ...options })).toThrow(message)
})
@@ -22,10 +22,10 @@ it('advertises configured models with an API key', async () => {
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' }),
]))
await ctx.plugin(ApiKey, { models: [{ id: 'configured-model' }] })
expect(await ctx.llm.listModels('deepseek-official')).toEqual([
expect.objectContaining({ provider: 'deepseek-official', id: 'configured-model' }),
])
} finally {
await ctx.fiber.dispose()
vi.unstubAllEnvs()
@@ -0,0 +1,161 @@
/** The API-key route serves the official /models listing while its catalog is the built-in one. */
import { afterEach, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import { liveConfig } from '../../../settings/settings/tests/live-config.ts'
import * as ApiKey from '../src/index.ts'
const LISTING_URL = 'https://api.deepseek.com/models'
const MESSAGES = { output_modalities: ['text'], api_capabilities: { anthropic_messages: {} } }
const FLASH = {
id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', context_window: 1_048_576, max_output_tokens: 393_216,
input_modalities: ['text', 'image'], output_modalities: ['text'],
api_capabilities: { anthropic_messages: { system_prompt_update: 'in-history' } },
}
const PRO = { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', context_window: 1_048_576, input_modalities: ['text'], ...MESSAGES }
const requires = (version: string) => ({ client_requirements: { 'deepseek-harness': { min_version: version } } })
const BUILT_IN = ['deepseek-flash', 'deepseek-v4-pro']
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
/** Answer the official listing URL with `reply`; any other request fails, so nothing reaches the network. */
function officialListing(reply: (request: Request, index: number) => Response) {
const requests: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
const request = new Request(input, init)
if (request.url !== LISTING_URL) return Promise.reject(new Error(`unexpected request to ${request.url}`))
requests.push(request)
return Promise.resolve(reply(request, requests.length - 1))
})
return requests
}
const listing = (...rows: object[]) => () => new Response(JSON.stringify({ object: 'list', data: rows }), { headers: { 'content-type': 'application/json' } })
async function host(key: string | undefined = 'fixture-api-key') {
vi.stubEnv('DEEPSEEK_API_KEY', key)
const diagnostics = vi.spyOn(console, 'error').mockImplementation(() => {})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmRuntime)
return { ctx, diagnostics, warn }
}
const ids = async (ctx: Context) => (await ctx.llm.listModels('deepseek-official')).map(model => model.id)
it('follows the official listing while the catalog is the built-in one', async () => {
const requests = officialListing(listing(FLASH, { ...PRO, id: 'deepseek-v5', ...requires('0.0.1') }, PRO))
const { ctx, diagnostics } = await host()
await ctx.plugin(ApiKey, {})
expect(await ids(ctx)).toEqual(['deepseek-flash', 'deepseek-v5', 'deepseek-v4-pro'])
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
context: { contextWindow: 1_048_576 }, systemPromptUpdate: 'in-history', toolUpdate: 'addition-only', inputModalities: ['text', 'image'],
})
expect(requests.map(request => request.headers.get('x-api-key'))).toEqual(['fixture-api-key'])
expect(diagnostics).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-api-key]', expect.stringContaining('loaded 3 model(s), skipped 0 row(s)') as string)
})
it('keeps a customized catalog without requesting the listing', async () => {
const requests = officialListing(listing(FLASH))
const { ctx } = await host()
await ctx.plugin(ApiKey, { models: [{ id: 'custom', name: 'Custom' }] })
expect(await ids(ctx)).toEqual(['custom'])
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'custom')).resolves.toMatchObject({ name: 'Custom' })
expect(requests).toEqual([])
})
it('serves the built-in catalog on another endpoint without requesting a listing', async () => {
const requests = officialListing(listing(FLASH))
const { ctx } = await host()
await ctx.plugin(ApiKey, { baseURL: 'http://127.0.0.1:9/anthropic' })
expect(await ids(ctx)).toEqual(BUILT_IN)
expect(requests).toEqual([])
})
it('falls back to the built-in catalog when the listing fails', async () => {
officialListing(() => new Response('down', { status: 503 }))
const { ctx, warn } = await host()
await ctx.plugin(ApiKey, {})
expect(await ids(ctx)).toEqual(BUILT_IN)
expect(warn).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-api-key]', expect.stringContaining('using the bundled catalog') as string)
})
it('refuses listed models this build cannot serve before any inference request', async () => {
const requests = officialListing(listing(
FLASH, { ...PRO, id: 'deepseek-v5' }, { ...PRO, id: 'deepseek-v5-next', ...requires('99.0.0-alpha.1') }, { id: 'deepseek-v4-pro' },
))
const { ctx, warn } = await host()
await ctx.plugin(ApiKey, {})
expect(await ids(ctx)).toEqual(['deepseek-flash'])
await expect(ctx.llm.prepareCall({ provider: 'deepseek-official', model: 'deepseek-v4-pro' })).rejects.toMatchObject({ code: 'MODEL_UNSUPPORTED' })
await expect(ctx.llm.prepareCall({ provider: 'deepseek-official', model: 'deepseek-v5' })).rejects.toMatchObject({ code: 'MODEL_UNSUPPORTED' })
await expect(ctx.llm.prepareCall({ provider: 'deepseek-official', model: 'deepseek-v5-next' })).rejects.toMatchObject({ code: 'CLIENT_UPGRADE_REQUIRED' })
await expect(ctx.llm.prepareCall({ provider: 'deepseek-official', model: 'unlisted-pass-through' })).resolves.toBeDefined()
expect(warn).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-api-key]', 'model "deepseek-v4-pro" declares no api_capabilities.anthropic_messages')
expect(requests).toHaveLength(1)
})
it("leaves a malformed replacement key to the request instead of refusing by the previous key's listing", async () => {
const requests = officialListing(listing(FLASH, PRO, { ...PRO, id: 'deepseek-v5-next', ...requires('99.0.0-alpha.1') }))
const { ctx } = await host()
vi.stubEnv('MALFORMED_DEEPSEEK_API_KEY', 'invalid\nheader')
const live = await liveConfig(ctx, ApiKey, {})
await expect(ctx.llm.prepareCall({ provider: 'deepseek-official', model: 'deepseek-v5-next' })).rejects.toMatchObject({ code: 'CLIENT_UPGRADE_REQUIRED' })
await live.update({ apiKeyEnv: 'MALFORMED_DEEPSEEK_API_KEY' })
await expect(ctx.llm.prepareCall({ provider: 'deepseek-official', model: 'deepseek-v5-next' })).resolves.toBeDefined()
expect(requests).toHaveLength(1)
})
it('lets a caller abandon the first listing wait', async () => {
const started = Promise.withResolvers<undefined>()
vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => new Promise((_resolve, reject) => {
started.resolve(undefined)
init?.signal?.addEventListener('abort', () => {
reject(new Error('listing request aborted', { cause: init.signal?.reason }))
}, { once: true })
}))
const { ctx } = await host()
await ctx.plugin(ApiKey, {})
const caller = new AbortController()
const pending = ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash', caller.signal)
await started.promise
caller.abort(new Error('caller left'))
await expect(pending).rejects.toThrow('caller left')
})
it.each([[{}], [{ models: [{ id: 'custom' }] }]])('hides the route without a key and requests nothing for %j', async (config) => {
const requests = officialListing(listing(FLASH))
const { ctx } = await host('')
await ctx.plugin(ApiKey, config)
expect(await ids(ctx)).toEqual([])
expect(requests).toEqual([])
})
it('reloads the listing once when the catalog returns to the built-in one', async () => {
const requests = officialListing((_request, index) => (index === 0 ? listing(FLASH, PRO) : listing(FLASH, PRO, { ...PRO, id: 'deepseek-v5', ...requires('0.0.1') }))())
const { ctx } = await host()
const live = await liveConfig(ctx, ApiKey, {})
expect(await ids(ctx)).toEqual(BUILT_IN)
await live.update({ models: [{ id: 'custom' }] })
expect(await ids(ctx)).toEqual(['custom'])
expect(requests).toHaveLength(1)
await live.replace({})
expect(await ids(ctx)).toEqual([...BUILT_IN, 'deepseek-v5'])
expect(requests).toHaveLength(2)
})
it('keeps following the listing across an update the adapter rejects', async () => {
const requests = officialListing(listing(FLASH, PRO))
const { ctx } = await host()
const live = await liveConfig(ctx, ApiKey, {})
expect(await ids(ctx)).toEqual(BUILT_IN)
await live.update({ thinking: 'disabled', reasoningEffort: 'high' })
await expect(ctx.llm.listModels('deepseek-official')).rejects.toThrow(/only reasoningEffort "off"/)
await live.replace({})
expect(await ids(ctx)).toEqual(BUILT_IN)
expect(requests).toHaveLength(1)
})
+6 -6
View File
@@ -21,8 +21,8 @@
en: 6850d08bc091ad23
zh: 2906e6cf1dfed6ce
/deepseek-ai-dsh-llm-deepseek/use-this-package/minimal-configuration:
en: cfc2de7ef728dbf9
zh: 2ce1a406061a71aa
en: 6a15f2e0efec4c37
zh: 7fb5f1e4a92b01ad
/deepseek-ai-dsh-llm-deepseek/use-this-package/endpoint-and-wire-format:
en: 522e74b770e9d4ca
zh: a8b261b41677bfe7
@@ -39,8 +39,8 @@
en: f13f24b504dcfb50
zh: 69b0ed9e2adf3394
/deepseek-ai-dsh-llm-deepseek/use-this-package/failures-and-recovery:
en: d43f055d2382ae71
zh: af2a7cb4e3476a3f
en: 40ab925291edc6ac
zh: 62c42ea3521920dc
/deepseek-ai-dsh-llm-deepseek/understand-the-implementation:
en: 1598ac8a8bb9b6bf
zh: fdd089c5ae7722cd
@@ -48,8 +48,8 @@
en: 829203c4c282dc46
zh: e05dada4a1420a95
/deepseek-ai-dsh-llm-deepseek/understand-the-implementation/source-map:
en: dc2d2409ad8c41cc
zh: 51fc4ad65dab6378
en: 584698e2c7686997
zh: 2aed9608d5317d7b
/deepseek-ai-dsh-llm-deepseek/understand-the-implementation/wire-flow:
en: 9477d8af3d762450
zh: 52a2967518509f94
+3 -3
View File
@@ -50,7 +50,7 @@ Choose this adapter for DeepSeek's official API or a Messages-compatible gateway
filesApiTimeoutMs: 60000
```
A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and core calls still pass unlisted model ids through as text-only routes. GUI selection 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 "<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 the 1,048,576-token context window the official model listing declares. An explicit list replaces those defaults, and core calls still pass unlisted model ids through as text-only routes. On the official endpoint the API-key route follows the service's model listing while `models` holds those defaults ([listing rules](../llm-deepseek-api-key/README.md#use-this-package)). 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 "<id>" systemPromptUpdate must be "in-history" when present`.
| Field | Default | Meaning |
|---|---|---|
@@ -118,7 +118,7 @@ Successful Files responses must contain valid JSON. JSON decoding failures from
Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_<status>` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. A request-extension field collision, or a rejected preparation from a directly supplied `prepareExtensions`, fails with `REQUEST_EXTENSION` and appends the cause chain to the message; a post-2xx acceptance failure is logged and the response still streams. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection removes every named mapping (or every mapping used by the attempt) in one upload-index update 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: the API-key route serves its configured `models` while its API key resolves, and the account route serves its account listing while signed in (see the [account provider](../llm-deepseek-account/README.md)). The transport supplies the bundled catalog, the catalog-entry field schemas, and capability resolution. A provider may also supply `connect(signal)`, which resolves the connection for exact-model lookups and prepared calls after provider-owned work such as a catalog load, and `admit(connection, model)`, which refuses a model before network I/O.
Provider plugins own catalog availability. While its API key resolves, the API-key route serves its configured `models`, or the official model listing while `models` is the built-in catalog on the official endpoint; the account route serves its account listing while signed in (see the [API-key](../llm-deepseek-api-key/README.md) and [account](../llm-deepseek-account/README.md) providers). The transport supplies the bundled catalog, the catalog-entry field schemas, and capability resolution, plus the listing machinery both routes share: `requestModelListing`, the credential-scoped `ModelListingCache`, and `resolveListedCatalog` with its client-version gate and `refuseListedModel`. A provider may also supply `connect(signal)`, which resolves the connection for exact-model lookups and prepared calls after provider-owned work such as a catalog load, and `admit(connection, model)`, which refuses a model before network I/O.
-----
@@ -136,7 +136,7 @@ The plugin is built on one explicit resolve step and one registration fact. `res
### Source map
[`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).
[`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). [`src/listing.ts`](src/listing.ts), [`src/listing-cache.ts`](src/listing-cache.ts), and [`src/listed-catalog.ts`](src/listed-catalog.ts) own the `/models` request and decoding, the listing cache, and listed-catalog resolution.
### Wire flow
+3 -3
View File
@@ -50,7 +50,7 @@ kind: "package-reference"
filesApiTimeoutMs: 60000
```
请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,核心调用中未列出的模型 id 仍作为纯文本路由原样通过。GUI 选择要求模型具有目录条目;条目消失后,已保存的选择仍可提交请求。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "<id>" systemPromptUpdate must be "in-history" when present` 失败。
请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为官方模型列表声明的 1,048,576 token。显式列表会替换这些默认值,核心调用中未列出的模型 id 仍作为纯文本路由原样通过。在官方端点上,当 `models` 保持这些默认值时,API key 路由改为使用服务端的模型列表([列表规则](../llm-deepseek-api-key/README.zh.md#use-this-package))。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` 失败。
| 字段 | 默认值 | 含义 |
|---|---|---|
@@ -118,7 +118,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_<status>`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展字段冲突,或直接提供的 `prepareExtensions` 拒绝准备时,使用 `REQUEST_EXTENSION` 失败,并在消息中附上原因链;2xx 后接受失败只记录告警,响应仍会继续流式返回。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会在一次上传索引更新中删除全部点名映射(或该次尝试使用的全部映射),并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。官方路由缺少 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。
提供方插件负责目录可用性:API key 路由在能解析到 API key 时提供配置中的 `models`,账号路由在登录时提供账号的模型列表(见[账号提供方](../llm-deepseek-account/README.zh.md))。传输层提供内置目录、目录条目字段的 schema 和能力解析。提供方还可以提供 `connect(signal)`:在提供方自己的工作(例如加载目录)完成后,为精确模型查询和预备调用解析连接;以及 `admit(connection, model)`:在网络请求之前拒绝某个模型。
提供方插件负责目录可用性:API key 路由在能解析到 API key 时提供配置中的 `models`,在官方端点上且 `models` 为内置目录时改为提供官方模型列表;账号路由在登录时提供账号的模型列表(见 [API key](../llm-deepseek-api-key/README.zh.md) 与[账号](../llm-deepseek-account/README.zh.md)提供方)。传输层提供内置目录、目录条目字段的 schema 和能力解析,以及两条路由共用的列表机制:`requestModelListing`、按凭据区分的 `ModelListingCache`,以及带客户端版本门控的 `resolveListedCatalog` 与 `refuseListedModel`。提供方还可以提供 `connect(signal)`:在提供方自己的工作(例如加载目录)完成后,为精确模型查询和预备调用解析连接;以及 `admit(connection, model)`:在网络请求之前拒绝某个模型。
-----
@@ -136,7 +136,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
### 源码导航
[`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) 管理上传复用与恢复。
[`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) 管理上传复用与恢复。[`src/listing.ts`](src/listing.ts)、[`src/listing-cache.ts`](src/listing-cache.ts) 和 [`src/listed-catalog.ts`](src/listed-catalog.ts) 负责 `/models` 请求与解码、列表缓存和列表目录解析。
### 协议流程
+4 -2
View File
@@ -44,7 +44,8 @@
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"eventsource-parser": "^3.1.0"
"eventsource-parser": "^3.1.0",
"semver": "^7.8.5"
},
"devDependencies": {
"@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*",
@@ -74,7 +75,8 @@
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-credentials-local": "workspace:*",
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-deepseek-account": "workspace:*"
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
"@types/semver": "^7.8.0"
},
"peerDependenciesMeta": {
"@deepseek-ai/cordis-plugin-loader": {
+11
View File
@@ -3,6 +3,7 @@ import type { Volatile } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { isVolatile } from '@deepseek-ai/cosmokit'
import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
@@ -120,6 +121,16 @@ const BASE_URL_ENV = 'DEEPSEEK_BASE_URL'
/** Complete protocol settings captured for one request operation. */
export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
/**
* Whether a resolved catalog is the built-in one, which is what a `models`
* setting nobody customized resolves to.
* @param models - catalog resolved by {@link resolveAdapterOptions}.
* @returns whether `models` equals the resolved built-in catalog, entry by entry and in order.
*/
export function isBuiltInCatalog(models: readonly DeepSeekCatalogModel[]): boolean {
return deepEqualJson(models, resolveModels(DEFAULT_MODELS))
}
/** Resolve, validate, and detach the advisory model catalog. */
function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
const seen = new Set<string>()
+9 -1
View File
@@ -1,5 +1,5 @@
/** DeepSeek Messages transport, request configuration, and model capabilities. */
export { catalogModelFields, deepSeekConfigFields, Config, plainOptions, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts'
export { catalogModelFields, deepSeekConfigFields, Config, isBuiltInCatalog, plainOptions, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts'
export type { Options, ResolvedDeepSeekOptions } from './config.ts'
export {
DEFAULT_CONTEXT_WINDOW,
@@ -39,3 +39,11 @@ export type { RequestDefaults } from './types.ts'
export { catalogModelInfo } from './model-info.ts'
export { registerDeepSeekProvider } from './host.ts'
export { LISTING_TIMEOUT, modelListingURL, parseModelListing, requestModelListing } from './listing.ts'
export type { ClientRequirement, ListedCapabilities, ListedFacts, ListingEntry, ModelListing } from './listing.ts'
export { DEFAULT_MODELS_MAX_AGE_MS, DEFAULT_MODELS_TIMEOUT_MS, modelListingConfigFields, resolveListingTiming } from './listing-config.ts'
export type { ListingSettings, ListingTiming, ModelListingConfig, ModelListingOptions } from './listing-config.ts'
export { ModelListingCache } from './listing-cache.ts'
export type { ListingCacheDependencies, ListingView } from './listing-cache.ts'
export { refuseListedModel, resolveListedCatalog } from './listed-catalog.ts'
export type { CatalogRefusal, ListedCatalog, ListedCatalogInput, ModelOverride } from './listed-catalog.ts'
@@ -1,10 +1,13 @@
/** The account catalog: version gating, this build's model knowledge, and deployment overrides. */
/** A listed catalog: version gating, this build's model knowledge, and deployment overrides. */
import semver from 'semver'
import { APP_IDENTITY, LlmError } from '@deepseek-ai/dsh-llm'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import type { DeepSeekCatalogModel } from '@deepseek-ai/dsh-llm-deepseek'
import type { ModelOverride } from './config.ts'
import { resolveAdapterOptions } from './config.ts'
import type { ListedFacts, ListingEntry } from './listing.ts'
import type { DeepSeekCatalogModel } from './types.ts'
/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */
export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
/** Why this build refuses one listed model before network I/O. */
export type CatalogRefusal =
@@ -12,7 +15,7 @@ export type CatalogRefusal =
| { readonly kind: 'unsupported' }
/** Inputs of one catalog resolution. */
export interface AccountCatalogInput {
export interface ListedCatalogInput {
/** Entries of the last successful listing, or `undefined` before one succeeds. */
readonly listing: readonly ListingEntry[] | undefined
/** Validated models this build supports, carrying its client-side knowledge. */
@@ -25,8 +28,8 @@ export interface AccountCatalogInput {
readonly clientVersion: string
}
/** One resolved account catalog. */
export interface AccountCatalog {
/** One resolved listed catalog. */
export interface ListedCatalog {
/** Selectable models in listing order, or this build's models when no listing succeeded. */
readonly offered: readonly DeepSeekCatalogModel[]
/** Entries request resolution consults: offered models, then supported models the listing does not offer. */
@@ -49,14 +52,20 @@ function layered(model: DeepSeekCatalogModel, layer: ModelOverride): DeepSeekCat
return merged
}
/** Layer one usable listing entry over this build's entry for the same id. */
/** Layer one usable listing entry over this build's entry for the same id; a declared `leading-only` mode removes the inherited one. */
function listed(base: DeepSeekCatalogModel, entry: ListingEntry, facts: ListedFacts, routeMaxTokens: number): DeepSeekCatalogModel {
const { systemPromptUpdate: _replaced, ...leadingOnly } = base
return layered(facts.systemPromptUpdate === 'leading-only' ? leadingOnly : base, {
const { systemPromptUpdate, toolUpdate, ...rest } = base
const inherited: DeepSeekCatalogModel = {
...rest,
...systemPromptUpdate === undefined || facts.systemPromptUpdate === 'leading-only' ? {} : { systemPromptUpdate },
...toolUpdate === undefined || facts.toolUpdate === 'leading-only' ? {} : { toolUpdate },
}
return layered(inherited, {
...entry.name === undefined ? {} : { name: entry.name },
...facts.contextWindow === undefined ? {} : { contextWindow: facts.contextWindow },
...facts.maxOutputTokens === undefined ? {} : { maxTokens: Math.min(routeMaxTokens, facts.maxOutputTokens) },
...facts.systemPromptUpdate === 'in-history' ? { systemPromptUpdate: 'in-history' as const } : {},
...facts.toolUpdate === undefined || facts.toolUpdate === 'leading-only' ? {} : { toolUpdate: facts.toolUpdate },
inputModalities: [...facts.inputModalities],
})
}
@@ -82,13 +91,13 @@ function gateOf(entry: ListingEntry, known: boolean, clientVersion: string, prob
}
/**
* Resolve the account catalog. A declared minimum client version decides a
* Resolve a route's catalog from its listing. A declared minimum client version decides a
* listed model's availability; without one, only models this build supports
* are offered and the other listed models stay hidden.
* @param input - listing, supported models, overrides, route cap, and this build's version.
* @returns offered models, request-resolution entries, refusals, and diagnostics.
*/
export function resolveAccountCatalog(input: AccountCatalogInput): AccountCatalog {
export function resolveListedCatalog(input: ListedCatalogInput): ListedCatalog {
const supported = new Map(input.supported.map(model => [model.id, model]))
const problems: string[] = []
const overrideErrors: string[] = []
@@ -127,7 +136,7 @@ export function resolveAccountCatalog(input: AccountCatalogInput): AccountCatalo
const listedIds = new Set(input.listing.map(entry => entry.id))
for (const id of Object.keys(input.overrides)) {
if (!listedIds.has(id) && !supported.has(id)) {
overrideErrors.push(`modelOverrides names "${id}", which neither the account listing nor the built-in catalog describes`)
overrideErrors.push(`modelOverrides names "${id}", which neither the model listing nor the built-in catalog describes`)
}
}
const resolution = resolveAdapterOptions({
@@ -135,3 +144,25 @@ export function resolveAccountCatalog(input: AccountCatalogInput): AccountCatalo
}).models
return { offered: resolution.slice(0, offered.length), resolution, refusals, problems, overrideErrors }
}
/**
* Refuse a listed model this build cannot serve, before any network I/O.
* @param model - requested wire model id.
* @param refusal - the catalog's refusal for `model`, or `undefined` when it is admitted.
* @throws LlmError `CLIENT_UPGRADE_REQUIRED` naming the required and current versions, or `MODEL_UNSUPPORTED`.
*/
export function refuseListedModel(model: string, refusal: CatalogRefusal | undefined): void {
if (refusal === undefined) return
switch (refusal.kind) {
case 'upgrade-required':
throw new LlmError(
`DeepSeek model "${model}" requires DeepSeek Harness ${refusal.requiredVersion} or later; current version is ${APP_IDENTITY.version}`,
'CLIENT_UPGRADE_REQUIRED',
)
case 'unsupported':
throw new LlmError(`DeepSeek model "${model}" is not supported by DeepSeek Harness; current version is ${APP_IDENTITY.version}`, 'MODEL_UNSUPPORTED')
/* v8 ignore next 2 -- catalog resolution produces only the closed refusal union */
default:
assertNever(refusal, 'listed catalog refusal')
}
}
@@ -1,18 +1,16 @@
/** Credential-scoped `/models` cache with single-flight loads, background refresh, and last-known-good retention. */
import { createHash } from 'node:crypto'
import { deadline } from '@deepseek-ai/dsh-timeout'
import type { CatalogSettings } from './config.ts'
import type { ListingSettings } from './listing-config.ts'
import { LISTING_TIMEOUT } from './listing.ts'
import type { ModelListing } from './listing.ts'
/** Host-owned inputs of one catalog instance. */
export interface CatalogDependencies {
/** Current endpoint settings, re-read on every access. */
readonly settings: () => CatalogSettings
/** The account token allowed for `url`, or `undefined` while signed out. */
readonly token: (url: string) => Promise<string | undefined>
/** Perform one listing request authenticated with `token`; `signal` carries the {@link LISTING_TIMEOUT} request deadline. */
readonly load: (url: string, token: string, signal: AbortSignal) => Promise<ModelListing>
/** Host-owned inputs of one cache instance. */
export interface ListingCacheDependencies {
/** The credential the route sends to `url`, or `undefined` when it has none. */
readonly credential: (url: string) => Promise<string | undefined>
/** Perform one listing request authenticated with `credential`; `signal` carries the {@link LISTING_TIMEOUT} request deadline. */
readonly load: (url: string, credential: string, signal: AbortSignal) => Promise<ModelListing>
/** Wall clock used for listing age. */
readonly now: () => number
/** Report a loaded listing. */
@@ -22,9 +20,9 @@ export interface CatalogDependencies {
}
/** What request resolution reads. */
export interface CatalogView {
/** Whether an account token is available for the listing endpoint. */
readonly signedIn: boolean
export interface ListingView {
/** Whether a credential is available for the listing endpoint. */
readonly authenticated: boolean
/** Last successful listing for this credential and endpoint; `undefined` until one succeeds. */
readonly listing: ModelListing | undefined
}
@@ -42,8 +40,8 @@ async function abandonable(task: Promise<void>, signal: AbortSignal | undefined)
}
}
/** One account route's listing cache; each endpoint and credential pair has its own scope. */
export class AccountModelCatalog {
/** One route's listing cache; each endpoint and credential pair has its own scope. */
export class ModelListingCache {
private scope: string | undefined
private listing: ModelListing | undefined
/** Settle time of the latest load in this scope; `undefined` before the first one settles. */
@@ -54,38 +52,43 @@ export class AccountModelCatalog {
private failing = false
private closed = false
/** @param dependencies - settings, credential, transport, clock, and diagnostic callbacks. */
constructor(private readonly dependencies: CatalogDependencies) {}
/** @param dependencies - credential, transport, clock, and diagnostic callbacks. */
constructor(private readonly dependencies: ListingCacheDependencies) {}
/**
* The last published view, without I/O.
* @returns the signed-in state and listing of the current scope.
* @returns the credential state and listing of the current scope.
*/
current(): CatalogView {
return { signedIn: this.scope !== undefined, listing: this.listing }
current(): ListingView {
return { authenticated: this.scope !== undefined, listing: this.listing }
}
/**
* Resolve the view for the current credential. The first access of a scope
* waits for its load, which the request deadline bounds; later accesses
* return at once and refresh a stale listing in the background.
* @param settings - endpoint and timing of the caller's configuration generation.
* @param signal - abandons this wait only; the shared load continues.
* @returns the view after any first load of the scope settles.
*/
async view(signal?: AbortSignal): Promise<CatalogView> {
const settings = this.dependencies.settings()
const token = await this.dependencies.token(settings.url)
async view(settings: ListingSettings, signal?: AbortSignal): Promise<ListingView> {
const credential = await this.dependencies.credential(settings.url)
signal?.throwIfAborted()
if (this.closed) return this.current()
const scope = token === undefined ? undefined : `${settings.url}\n${createHash('sha256').update(token).digest('hex')}`
const scope = credential === undefined ? undefined : `${settings.url}\n${createHash('sha256').update(credential).digest('hex')}`
if (scope !== this.scope) this.rescope(scope)
if (token !== undefined) {
if (this.settledAt === undefined) await abandonable(this.start(settings, token), signal)
else if (this.dependencies.now() - this.settledAt >= settings.maxAgeMs) void this.start(settings, token)
if (credential !== undefined) {
if (this.settledAt === undefined) await abandonable(this.start(settings, credential), signal)
else if (this.dependencies.now() - this.settledAt >= settings.maxAgeMs) void this.start(settings, credential)
}
return this.current()
}
/** Discard the listing and abort any in-flight load; the next access loads again and waits like a first access. */
invalidate(): void {
this.rescope(this.scope)
}
/** Abort in-flight loads; later results and accesses change nothing. */
dispose(): void {
this.closed = true
@@ -103,14 +106,14 @@ export class AccountModelCatalog {
this.generation += 1
}
private start(settings: CatalogSettings, token: string): Promise<void> {
private start(settings: ListingSettings, credential: string): Promise<void> {
if (this.inflight !== undefined) return this.inflight
this.generation += 1
const generation = this.generation
const request = new AbortController()
this.request = request
const limit = deadline(request.signal, settings.timeoutMs, LISTING_TIMEOUT)
const run = this.dependencies.load(settings.url, token, limit.signal)
const run = this.dependencies.load(settings.url, credential, limit.signal)
.then((listing) => {
if (generation !== this.generation) return
this.listing = listing
@@ -0,0 +1,64 @@
/** Configuration of a route that follows a `/models` listing: cache timing and its validation. */
import type { Volatile } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
/** Default deadline of one model-listing request and of a first-use wait. */
export const DEFAULT_MODELS_TIMEOUT_MS = 5_000
/** Default listing age after which the next access refreshes it in the background. */
export const DEFAULT_MODELS_MAX_AGE_MS = 600_000
/** Listing fields of a route Config. */
export interface ModelListingConfig {
/** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */
modelsTimeoutMs: Volatile<number>
/**
* Listing age after which the next access refreshes it in the background;
* a failed load retries after the same age (default ten minutes).
*/
modelsMaxAgeMs: Volatile<number>
}
/** Schemas of the {@link ModelListingConfig} fields. */
export const modelListingConfigFields = {
modelsTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_MODELS_TIMEOUT_MS).volatile(),
modelsMaxAgeMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MODELS_MAX_AGE_MS).volatile(),
}
/** Plain listing options accepted by {@link resolveListingTiming}. */
export interface ModelListingOptions {
modelsTimeoutMs?: number
modelsMaxAgeMs?: number
}
/** Listing cache timing of one configuration generation. */
export interface ListingTiming {
/** Deadline of one listing request. */
readonly timeoutMs: number
/** Listing age after which the next access refreshes it. */
readonly maxAgeMs: number
}
/** Listing endpoint and cache timing of one configuration generation. */
export interface ListingSettings extends ListingTiming {
/** Listing endpoint; it always shares the Messages origin. */
readonly url: string
}
/**
* Validate listing timing. Settings updates can reach it without schema validation, so every bound is judged here.
* @param options - plain listing options; absent values take the defaults.
* @param owner - plugin name that prefixes each failure message.
* @returns the validated timing.
* @throws Error when a value is outside its schema bounds.
*/
export function resolveListingTiming(options: ModelListingOptions, owner: string): ListingTiming {
const { modelsTimeoutMs = DEFAULT_MODELS_TIMEOUT_MS, modelsMaxAgeMs = DEFAULT_MODELS_MAX_AGE_MS } = options
if (!Number.isFinite(modelsTimeoutMs) || modelsTimeoutMs <= 0 || modelsTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(`${owner}: modelsTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
if (!Number.isSafeInteger(modelsMaxAgeMs) || modelsMaxAgeMs < 0) {
throw new Error(`${owner}: modelsMaxAgeMs must be a non-negative safe integer`)
}
return { timeoutMs: modelsTimeoutMs, maxAgeMs: modelsMaxAgeMs }
}
@@ -8,6 +8,15 @@ const LISTED_MODALITIES = ['text', 'image'] as const satisfies readonly ModelMod
/** Code of a listing deadline's timeout reason; {@link requestModelListing} reports it as `TIMEOUT`. */
export const LISTING_TIMEOUT = 'MODEL_LISTING_TIMEOUT'
/**
* The listing endpoint of a Messages root.
* @param baseURL - Messages `baseURL`.
* @returns `/models` at the origin of `baseURL`, the only origin that receives the route's credential.
*/
export function modelListingURL(baseURL: string): string {
return new URL('/models', new URL(baseURL).origin).href
}
/** The requirement one listing entry declares under this client's product token. */
export type ClientRequirement =
| { readonly kind: 'none' }
@@ -24,6 +33,8 @@ export interface ListedFacts {
readonly maxOutputTokens?: number
/** Messages system prompt mode, when declared; any value other than `in-history` reads as `leading-only`. */
readonly systemPromptUpdate?: 'in-history' | 'leading-only'
/** Messages tool update mode, when declared; any value other than `in-history` or `addition-only` reads as `leading-only`. */
readonly toolUpdate?: 'in-history' | 'addition-only' | 'leading-only'
}
/** Declared capabilities, or why the Messages transport cannot drive the model. */
@@ -97,14 +108,19 @@ function capabilitiesOf(entry: Fields): ListedCapabilities {
if (contextWindow === false || maxOutputTokens === false) {
return { usable: false, reason: 'declares an invalid context_window or max_output_tokens' }
}
const update = messages.system_prompt_update
// `null` declares nothing, like an absent field, so a serializer that writes empty values as null keeps this build's knowledge.
const promptUpdate = messages.system_prompt_update ?? undefined
const toolUpdate = messages.tool_update ?? undefined
return {
usable: true,
facts: {
inputModalities: inputs,
...contextWindow === undefined ? {} : { contextWindow },
...maxOutputTokens === undefined ? {} : { maxOutputTokens },
...update === undefined ? {} : { systemPromptUpdate: update === 'in-history' ? 'in-history' : 'leading-only' },
...promptUpdate === undefined ? {} : { systemPromptUpdate: promptUpdate === 'in-history' ? 'in-history' : 'leading-only' },
...toolUpdate === undefined ? {} : {
toolUpdate: toolUpdate === 'in-history' || toolUpdate === 'addition-only' ? toolUpdate : 'leading-only',
},
},
}
}
+5 -3
View File
@@ -1,13 +1,15 @@
/** Default DeepSeek model catalog. */
import { DEFAULT_CONTEXT_WINDOW } from './defaults.ts'
import type { DeepSeekCatalogModel } from './types.ts'
/** Context window of both official models, as the official `/models` listing declares it. */
const OFFICIAL_CONTEXT_WINDOW = 1_048_576
/** Advisory official model entries; deployments may replace the catalog. */
export const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
{
id: 'deepseek-flash',
name: 'DeepSeek-V4.1-Flash',
contextWindow: DEFAULT_CONTEXT_WINDOW,
contextWindow: OFFICIAL_CONTEXT_WINDOW,
inputModalities: ['text', 'image'],
systemPromptUpdate: 'in-history',
toolUpdate: 'addition-only',
@@ -16,6 +18,6 @@ export const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
contextWindow: DEFAULT_CONTEXT_WINDOW,
contextWindow: OFFICIAL_CONTEXT_WINDOW,
},
]
@@ -0,0 +1,18 @@
/** Recognizing the built-in catalog, which is how an uncustomized `models` setting resolves. */
import { expect, it } from 'vitest'
import { Config, isBuiltInCatalog, plainOptions, resolveAdapterOptions } from '../src/config.ts'
import { DEFAULT_MODELS } from '../src/models.ts'
it('recognizes the built-in catalog however it was supplied', () => {
expect(isBuiltInCatalog(resolveAdapterOptions({}).models)).toBe(true)
expect(isBuiltInCatalog(resolveAdapterOptions({ models: structuredClone(DEFAULT_MODELS) }).models)).toBe(true)
expect(isBuiltInCatalog(resolveAdapterOptions(plainOptions(Config({}))).models)).toBe(true)
})
it('treats any other catalog as customized', () => {
const [flash, pro] = resolveAdapterOptions({}).models
expect(isBuiltInCatalog([flash!])).toBe(false)
expect(isBuiltInCatalog([{ ...flash!, contextWindow: 524_288 }, pro!])).toBe(false)
expect(isBuiltInCatalog([pro!, flash!])).toBe(false)
expect(isBuiltInCatalog([])).toBe(false)
})
@@ -1,9 +1,10 @@
/** Catalog resolution: version gating, this build's model knowledge, and model overrides. */
import { describe, expect, it } from 'vitest'
import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import type { ModelOverride } from '../src/config.ts'
import { resolveAdapterOptions } from '../src/config.ts'
import { resolveListedCatalog } from '../src/listed-catalog.ts'
import type { ModelOverride } from '../src/listed-catalog.ts'
import { parseModelListing } from '../src/listing.ts'
import { resolveAccountCatalog } from '../src/resolve-catalog.ts'
import { DEFAULT_REQUEST_IMAGE_MAX_BYTES } from '../src/request-pricing.ts'
const FLASH = {
id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', context_window: 1_048_576, max_output_tokens: 393_216,
@@ -16,7 +17,7 @@ const PRO = {
api_capabilities: { anthropic_messages: { system_prompt_update: 'leading-only' } },
}
const requires = (version: unknown) => ({ client_requirements: { 'deepseek-harness': { min_version: version } } })
const resolve = (rows: object[] | undefined, overrides: Record<string, ModelOverride> = {}) => resolveAccountCatalog({
const resolve = (rows: object[] | undefined, overrides: Record<string, ModelOverride> = {}) => resolveListedCatalog({
listing: rows === undefined ? undefined : parseModelListing({ data: rows }).entries,
supported: resolveAdapterOptions({}).models,
overrides,
@@ -28,7 +29,7 @@ describe('without a listing', () => {
it('offers the bundled catalog', () => {
const catalog = resolve(undefined)
expect(catalog.offered.map(model => [model.id, model.name, model.contextWindow])).toEqual([
['deepseek-flash', 'DeepSeek-V4.1-Flash', 1_000_000], ['deepseek-v4-pro', 'DeepSeek-V4-Pro', 1_000_000],
['deepseek-flash', 'DeepSeek-V4.1-Flash', 1_048_576], ['deepseek-v4-pro', 'DeepSeek-V4-Pro', 1_048_576],
])
expect(catalog.resolution).toEqual(catalog.offered)
expect(catalog.refusals.size).toBe(0)
@@ -56,7 +57,7 @@ describe('with a listing', () => {
it('keeps this build\'s values for fields a listing omits', () => {
const [pro] = resolve([{ id: 'deepseek-v4-pro', api_capabilities: { anthropic_messages: {} } }]).offered
expect(pro).toMatchObject({ name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000, inputModalities: ['text'] })
expect(pro).toMatchObject({ name: 'DeepSeek-V4-Pro', contextWindow: 1_048_576, inputModalities: ['text'] })
expect(pro).not.toHaveProperty('maxTokens')
})
@@ -74,6 +75,28 @@ describe('with a listing', () => {
expect(flash).not.toHaveProperty('systemPromptUpdate')
})
it.each(['in-history', 'addition-only'])('takes a listed %s tool update mode over this build\'s', (toolUpdate) => {
const [flash] = resolve([{ ...FLASH, api_capabilities: { anthropic_messages: { system_prompt_update: 'in-history', tool_update: toolUpdate } } }]).offered
expect(flash?.toolUpdate).toBe(toolUpdate)
})
it('removes an inherited tool update mode the listing declares unsupported', () => {
const [flash] = resolve([{ ...FLASH, api_capabilities: { anthropic_messages: { system_prompt_update: 'in-history', tool_update: 'leading-only' } } }]).offered
expect(flash).not.toHaveProperty('toolUpdate')
expect(flash?.systemPromptUpdate).toBe('in-history')
})
it('keeps this build\'s protocol modes when the listing declares them null', () => {
const undeclared = { system_prompt_update: null, tool_update: null }
const [flash] = resolve([{ ...FLASH, api_capabilities: { anthropic_messages: undeclared } }]).offered
expect(flash).toMatchObject({ systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' })
})
it('declares a listed tool update mode on a vouched unknown model', () => {
const [model] = resolve([{ ...FLASH, id: 'deepseek-v5', ...requires('0.1.0'), api_capabilities: { anthropic_messages: { tool_update: 'in-history' } } }]).offered
expect(model?.toolUpdate).toBe('in-history')
})
it('drops inherited image limits from a model the listing makes text-only', () => {
const [flash] = resolve([{ ...FLASH, input_modalities: ['text'] }]).offered
expect(flash).toMatchObject({ inputModalities: ['text'] })
@@ -136,7 +159,7 @@ describe('with a listing', () => {
expect(catalog.offered[0]).not.toHaveProperty('imageMaxBytes')
expect(catalog.overrideErrors).toEqual([
expect.stringMatching(/^modelOverrides\.deepseek-v4-pro does not apply \(.*cannot declare image request limits/),
'modelOverrides names "retired", which neither the account listing nor the built-in catalog describes',
'modelOverrides names "retired", which neither the model listing nor the built-in catalog describes',
])
})
})
@@ -1,7 +1,8 @@
/** Credential-scoped listing cache: first-use waits, background refresh, retention, and disposal. */
import { expect, it, vi } from 'vitest'
import { timeoutOf } from '@deepseek-ai/dsh-timeout'
import { AccountModelCatalog } from '../src/catalog.ts'
import { ModelListingCache } from '../src/listing-cache.ts'
import type { ListingSettings } from '../src/listing-config.ts'
import { LISTING_TIMEOUT } from '../src/listing.ts'
import type { ModelListing } from '../src/listing.ts'
@@ -16,9 +17,9 @@ function harness(timeoutMs = 60_000, honorsAbort = true) {
const loads: Array<{ token: string; signal: AbortSignal; settle: PromiseWithResolvers<ModelListing> }> = []
const info = vi.fn()
const warn = vi.fn()
const catalog = new AccountModelCatalog({
settings: () => ({ url: 'https://api.example/models', timeoutMs, maxAgeMs: 100 }),
token: () => Promise.resolve(token),
const settings: ListingSettings = { url: 'https://api.example/models', timeoutMs, maxAgeMs: 100 }
const cache = new ModelListingCache({
credential: () => Promise.resolve(token),
load: (_url, value, signal) => {
const settle = Promise.withResolvers<ModelListing>()
if (honorsAbort) signal.addEventListener('abort', () => { settle.reject(signal.reason) }, { once: true })
@@ -30,17 +31,24 @@ function harness(timeoutMs = 60_000, honorsAbort = true) {
warn,
})
const flush = () => new Promise(resolve => setImmediate(resolve))
// Every access passes the same configuration generation.
const catalog = {
view: (signal?: AbortSignal) => cache.view(settings, signal),
current: () => cache.current(),
invalidate: () => { cache.invalidate() },
dispose: () => { cache.dispose() },
}
return {
catalog, loads, info, warn, flush,
advance: (ms: number) => { clock += ms },
account: (value: string | undefined) => { token = value },
credential: (value: string | undefined) => { token = value },
}
}
it('reports a signed-out view without loading', async () => {
const { catalog, loads, account } = harness()
account(undefined)
await expect(catalog.view()).resolves.toEqual({ signedIn: false, listing: undefined })
it('reports an unauthenticated view without loading', async () => {
const { catalog, loads, credential } = harness()
credential(undefined)
await expect(catalog.view()).resolves.toEqual({ authenticated: false, listing: undefined })
expect(loads).toEqual([])
})
@@ -51,15 +59,15 @@ it('shares one first load between concurrent callers', async () => {
await flush()
expect(loads).toHaveLength(1)
loads[0]!.settle.resolve(listing('a'))
await expect(first).resolves.toEqual({ signedIn: true, listing: listing('a') })
await expect(second).resolves.toEqual({ signedIn: true, listing: listing('a') })
expect(catalog.current()).toEqual({ signedIn: true, listing: listing('a') })
await expect(first).resolves.toEqual({ authenticated: true, listing: listing('a') })
await expect(second).resolves.toEqual({ authenticated: true, listing: listing('a') })
expect(catalog.current()).toEqual({ authenticated: true, listing: listing('a') })
expect(info).toHaveBeenCalledExactlyOnceWith('model listing from https://api.example/models loaded 1 model(s), skipped 0 row(s)')
})
it('bounds the first wait by the request deadline and serves no listing after a failure', async () => {
const { catalog, loads, warn } = harness(5)
await expect(catalog.view()).resolves.toEqual({ signedIn: true, listing: undefined })
await expect(catalog.view()).resolves.toEqual({ authenticated: true, listing: undefined })
expect(timeoutOf(loads[0]!.signal, LISTING_TIMEOUT)?.timeoutMs).toBe(5)
expect(warn).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('using the bundled catalog'))
})
@@ -114,11 +122,11 @@ it('refreshes a stale listing in the background and keeps it through a failing s
expect(warn).toHaveBeenCalledTimes(2)
})
it('rescopes on another account and discards the previous account\'s result', async () => {
const { catalog, loads, flush, account } = harness()
it('rescopes on another credential and discards the previous credential\'s result', async () => {
const { catalog, loads, flush, credential } = harness()
const first = catalog.view()
await flush()
account('token-b')
credential('token-b')
const second = catalog.view()
await flush()
expect(loads[0]!.signal.aborted).toBe(true)
@@ -127,8 +135,8 @@ it('rescopes on another account and discards the previous account\'s result', as
await expect(second).resolves.toMatchObject({ listing: listing('b') })
await first
expect(catalog.current().listing).toEqual(listing('b'))
account(undefined)
await expect(catalog.view()).resolves.toEqual({ signedIn: false, listing: undefined })
credential(undefined)
await expect(catalog.view()).resolves.toEqual({ authenticated: false, listing: undefined })
})
it('aborts in-flight work on dispose and loads nothing afterwards', async () => {
@@ -138,7 +146,7 @@ it('aborts in-flight work on dispose and loads nothing afterwards', async () =>
catalog.dispose()
expect(loads[0]!.signal.aborted).toBe(true)
await waiting
await expect(catalog.view()).resolves.toEqual({ signedIn: true, listing: undefined })
await expect(catalog.view()).resolves.toEqual({ authenticated: true, listing: undefined })
expect(loads).toHaveLength(1)
expect(warn).not.toHaveBeenCalled()
})
@@ -152,3 +160,32 @@ it('drops a successful result that settles after disposal', async () => {
await waiting
expect(catalog.current().listing).toBeUndefined()
})
it('discards the listing on invalidation, so the next access waits for a fresh load', async () => {
const { catalog, loads, flush } = harness()
const first = catalog.view()
await flush()
loads[0]!.settle.resolve(listing('a'))
await first
await catalog.view()
expect(loads).toHaveLength(1)
catalog.invalidate()
expect(catalog.current()).toEqual({ authenticated: true, listing: undefined })
const second = catalog.view()
await flush()
expect(loads).toHaveLength(2)
loads[1]!.settle.resolve(listing('b'))
await expect(second).resolves.toEqual({ authenticated: true, listing: listing('b') })
})
it('aborts an in-flight load on invalidation and ignores its result', async () => {
const { catalog, loads, flush, warn } = harness(60_000, false)
const waiting = catalog.view()
await flush()
catalog.invalidate()
expect(loads[0]!.signal.aborted).toBe(true)
loads[0]!.settle.resolve(listing('stale'))
await waiting
expect(catalog.current().listing).toBeUndefined()
expect(warn).not.toHaveBeenCalled()
})
@@ -7,7 +7,7 @@ import { deadline } from '@deepseek-ai/dsh-timeout'
import { LISTING_TIMEOUT, parseModelListing, requestModelListing } from '../src/listing.ts'
import type { ListedCapabilities } from '../src/listing.ts'
/** The account listing the service returned on 2026-09-24. */
/** The listing the service returned to an account token on 2026-09-24. */
const LISTING = {
object: 'list',
data: [
@@ -92,6 +92,23 @@ describe('parseModelListing', () => {
})?.capabilities).toEqual({ usable: true, facts: { inputModalities: ['text'], systemPromptUpdate: 'leading-only' } })
expect(only(MESSAGES)?.capabilities).toEqual({ usable: true, facts: { inputModalities: ['text'] } })
})
it.each([
['addition-only', 'addition-only'],
['in-history', 'in-history'],
['leading-only', 'leading-only'],
['future-mode', 'leading-only'],
[false, 'leading-only'],
[1, 'leading-only'],
])('reads tool_update %j as %s', (declared, toolUpdate) => {
expect(only({ api_capabilities: { anthropic_messages: { tool_update: declared } } })?.capabilities)
.toEqual({ usable: true, facts: { inputModalities: ['text'], toolUpdate } })
})
it('reads a null protocol mode as undeclared', () => {
expect(only({ api_capabilities: { anthropic_messages: { system_prompt_update: null, tool_update: null } } })?.capabilities)
.toEqual({ usable: true, facts: { inputModalities: ['text'] } })
})
})
describe('requestModelListing', () => {
@@ -36,6 +36,7 @@ afterEach(async () => {
root = undefined
await closeMockServers()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
async function loadComposition(
@@ -127,6 +128,8 @@ async function loadComposition(
describe('llm-deepseek real dynamic composition', () => {
it('updates the model catalog when the stored API key is removed or added', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
// The official endpoint's model listing is unavailable, so the route serves its built-in catalog offline.
vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve(new Response(null, { status: 404 })))
const { ctx } = await loadComposition({ withDynamic: true, baseURL: 'https://api.deepseek.com/anthropic' })
const credentials = ctx.get('credentials')!
const models = await ctx.llm.listModels('deepseek-official')
@@ -146,6 +149,33 @@ describe('llm-deepseek real dynamic composition', () => {
} finally { resolve.mockRestore() }
})
it('follows the official model listing and reloads it once the customized catalog is restored', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
vi.spyOn(console, 'error').mockImplementation(() => {})
const keys: Array<string | null> = []
const messages = { api_capabilities: { anthropic_messages: {} } }
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
const request = new Request(input, init)
if (request.url !== 'https://api.deepseek.com/models') return Promise.reject(new Error(`unexpected request to ${request.url}`))
keys.push(request.headers.get('x-api-key'))
return Promise.resolve(new Response(JSON.stringify({ data: [
{ id: 'deepseek-flash', ...messages },
{ id: 'deepseek-v5', ...messages, client_requirements: { 'deepseek-harness': { min_version: '0.0.1' } } },
] })))
})
const { ctx } = await loadComposition({ withDynamic: true, baseURL: 'https://api.deepseek.com/anthropic' })
const ids = async () => (await ctx.llm.listModels('deepseek-official')).map(model => model.id)
expect(await ids()).toEqual(['deepseek-flash', 'deepseek-v5'])
// The Models page writes these path operations for an edited catalog and for Restore defaults.
await ctx.settings.mutate(NS, [{ op: 'set', path: ['models'], value: [{ id: 'custom' }] }])
expect(await ids()).toEqual(['custom'])
expect(keys).toEqual(['boot-key'])
await ctx.settings.mutate(NS, [{ op: 'unset', path: ['models'] }])
expect(await ids()).toEqual(['deepseek-flash', 'deepseek-v5'])
expect(keys).toEqual(['boot-key', 'boot-key'])
})
it('keeps package inventory on when the Loader composition disables session upload', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
@@ -0,0 +1,39 @@
/** Turns on the official endpoint report credential failures on the model request, after the prompt is recorded. */
import { afterEach, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm'
import AgentRegistry 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 from '@deepseek-ai/dsh-tools'
import * as DeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key'
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
it('records the prompt and fails the request when the official endpoint has a malformed key', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'invalid\nheader')
const fetch = vi.spyOn(globalThis, 'fetch')
const ctx = new Context()
contexts.push(ctx)
for (const plugin of [LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, AgentRegistry]) {
await ctx.plugin(plugin)
}
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(DeepSeek, {})
const agent = await ctx.agentLoop.create(SessionId('malformed-official-key'), { provider: 'deepseek-official', model: 'deepseek-flash' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
await agent.whenIdle()
const events = agent.session.snapshotEvents()
expect(events.map(event => event.type)).toContain('user/message')
expect(events.find(event => event.type === 'assistant/attempt')?.data).toMatchObject({
stream: [{ chunk: { type: 'finish', reason: { kind: 'error', failure: { code: 'INVALID_CREDENTIAL' } } } }],
})
expect(fetch).not.toHaveBeenCalled()
})
@@ -1810,7 +1810,7 @@ describe('plugin registration and config', () => {
name: 'DeepSeek-V4.1-Flash',
inputModalities: ['text', 'image'],
systemPromptUpdate: 'in-history',
context: { contextWindow: 1_000_000 },
context: { contextWindow: 1_048_576 },
defaultMaxTokens: 256_000,
reasoning: {
efforts: [
@@ -1832,7 +1832,7 @@ describe('plugin registration and config', () => {
expect(info).toMatchObject({
id: 'deepseek-v4-pro',
inputModalities: ['text'],
context: { contextWindow: 1_000_000 },
context: { contextWindow: 1_048_576 },
defaultMaxTokens: 256_000,
})
expect(info?.systemPromptUpdate).toBeUndefined()
+3 -1
View File
@@ -1003,7 +1003,7 @@ describe('HarnessSdkJsonRpcServer', () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-'))
const ctx = await makeHarness(storageDir)
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
await ctx.plugin(LlmDeepSeek)
await ctx.plugin(LlmDeepSeek, { models: [{ id: 'preinstalled-model' }] })
try {
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
const inspect = server as unknown as { hasAdapterFor(provider: string): boolean }
@@ -1153,6 +1153,8 @@ describe('HarnessSdkJsonRpcServer', () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unsupported-reasoning-'))
const ctx = await makeHarness(storageDir)
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
// A loopback endpoint keeps the fallback-mounted official route off the network; no request is sent.
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:9/anthropic')
try {
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
await expect(server.handleRequest('initialize', {
+6 -9
View File
@@ -11061,6 +11061,9 @@ importers:
eventsource-parser:
specifier: ^3.1.0
version: 3.1.0
semver:
specifier: ^7.8.5
version: 7.8.5
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
@@ -11146,21 +11149,18 @@ importers:
'@deepseek-ai/dsh-tools':
specifier: workspace:*
version: link:../../core/tools
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
packages/llm/llm-deepseek-account:
dependencies:
'@deepseek-ai/cosmokit':
specifier: link:../../../vendor/cosmokit
version: link:../../../vendor/cosmokit
'@deepseek-ai/dsh-util-values':
specifier: workspace:*
version: link:../../util/values
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
semver:
specifier: ^7.8.5
version: 7.8.5
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
@@ -11186,9 +11186,6 @@ importers:
'@deepseek-ai/dsh-timeout':
specifier: workspace:*
version: link:../../util/timeout
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
packages/llm/llm-deepseek-api-key:
dependencies:
@@ -0,0 +1,26 @@
/** Unit suites cannot reach the official DeepSeek model listing unless they answer `fetch` themselves. */
import { createServer } from 'node:http'
import { afterEach, expect, it } from 'vitest'
import type { Server } from 'node:http'
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
})
it.each([
['string', () => fetch('https://api.deepseek.com/models')],
['URL', () => fetch(new URL('https://api.deepseek.com/models'))],
['Request', () => fetch(new Request('https://api.deepseek.com/models', { headers: { 'x-api-key': 'fixture-key' } }))],
])('rejects the official model listing given as a %s', async (_kind, request) => {
await expect(request()).rejects.toThrow('unit suites do not reach https://api.deepseek.com/models; answer it with a fetch stub')
})
it('passes every other request to the ambient fetch', async () => {
const server = createServer((_request, response) => { response.end('local') })
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
await expect((await fetch(`http://127.0.0.1:${address.port}/models`)).text()).resolves.toBe('local')
})
+22
View File
@@ -0,0 +1,22 @@
/**
* Keep unit suites from requesting the official DeepSeek model listing.
*
* The API-key and account routes request `GET https://api.deepseek.com/models` whenever they hold a
* credential for the official endpoint, and suites routinely boot those routes with a dummy key or
* inherit a developer's real one. Such a request would reach production and, on a network that never
* answers, hold the route's first catalog access for its whole listing deadline. Here it fails at once,
* so the route serves its built-in catalog; a suite that exercises the listing replaces `fetch` itself.
* @module
*/
/** The listing endpoint of the official DeepSeek API. */
const OFFICIAL_LISTING = 'https://api.deepseek.com/models'
const ambientFetch = globalThis.fetch
globalThis.fetch = (input, init) => {
const url = input instanceof Request ? input.url : input instanceof URL ? input.href : input
if (url === OFFICIAL_LISTING) {
return Promise.reject(new TypeError(`unit suites do not reach ${OFFICIAL_LISTING}; answer it with a fetch stub`))
}
return ambientFetch(input, init)
}
+9 -3
View File
@@ -182,7 +182,9 @@ export default defineConfig({
resolve: { alias: claudeCodeTestingAliases },
test: {
...localWorkerBudget,
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-dom-environment.ts'],
setupFiles: [
'./scripts/test-proxy-environment.ts', './scripts/test-dom-environment.ts', './scripts/test-official-listing-guard.ts',
],
// .tsx: client component specs (jsdom via per-file @vitest-environment pragma).
include: testIncludes,
exclude: platformUnsupportedTests,
@@ -200,7 +202,9 @@ export default defineConfig({
// Linux, and Windows. Forked workers avoid that shared thread path.
pool: 'forks',
...laneTestBudget,
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-dom-environment.ts'],
setupFiles: [
'./scripts/test-proxy-environment.ts', './scripts/test-dom-environment.ts', './scripts/test-official-listing-guard.ts',
],
include: testIncludes,
exclude: [
...platformUnsupportedTests,
@@ -217,7 +221,9 @@ export default defineConfig({
execArgv: vitestExecArgv,
pool: 'forks',
...laneTestBudget,
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-dom-environment.ts'],
setupFiles: [
'./scripts/test-proxy-environment.ts', './scripts/test-dom-environment.ts', './scripts/test-official-listing-guard.ts',
],
include: processBoundTests,
exclude: [
...platformUnsupportedTests,