diff --git a/packages/llm/llm-deepseek-api-key/README.i18n.yaml b/packages/llm/llm-deepseek-api-key/README.i18n.yaml
index ed861f822b..d04fe66ebe 100644
--- a/packages/llm/llm-deepseek-api-key/README.i18n.yaml
+++ b/packages/llm/llm-deepseek-api-key/README.i18n.yaml
@@ -15,11 +15,11 @@
en: 23386452a66b2fbd
zh: 4cf93c4bf17b3da2
/deepseek-ai-dsh-llm-deepseek-api-key/use-this-package:
- en: ab3d8cc5be370482
- zh: 8a8b9871d5afe1df
+ en: b5e9673e21e6c635
+ zh: e9f8eb0141c6e0da
/deepseek-ai-dsh-llm-deepseek-api-key/understand-the-implementation:
- en: a2a213e5629a569f
- zh: eb23478d0081270a
+ en: 20ba5b235f0d989b
+ zh: b18aec33828852ea
/deepseek-ai-dsh-llm-deepseek-api-key/further-exploration:
en: e8bf99d78aa7035f
zh: e4a74d2d48b6d271
diff --git a/packages/llm/llm-deepseek-api-key/README.md b/packages/llm/llm-deepseek-api-key/README.md
index 4b22ef2ae9..de402f3231 100644
--- a/packages/llm/llm-deepseek-api-key/README.md
+++ b/packages/llm/llm-deepseek-api-key/README.md
@@ -25,7 +25,7 @@ Authentication resolution returns the validated API key in `x-api-key` for both
## Use this package
-`apiKeyEnv` defaults to `DEEPSEEK_API_KEY` and resolves per request. When the credentials service exists, its precedence applies; only compositions without that service read the launch environment directly. Requests with missing credentials fail with `MISSING_CREDENTIAL`; malformed credentials fail with `INVALID_CREDENTIAL`. Model discovery returns the configured catalog regardless of credentials.
+`apiKeyEnv` defaults to `DEEPSEEK_API_KEY` and resolves per request. When the credentials service exists, its precedence applies; only compositions without that service read the launch environment directly. Missing credentials fail with `MISSING_CREDENTIAL` and yield an empty catalog, so the route offers no models until its key is stored; malformed credentials fail with `INVALID_CREDENTIAL`.
```yaml
- id: llm-deepseek
@@ -42,7 +42,7 @@ The endpoint and credential reference come from one configuration resolution. In
## Understand the implementation
-Registrations and listeners dispose with the plugin. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route. No invariant companion is published: discovery derives directly from configuration without an independent copy.
+Registrations and listeners dispose with the plugin. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route. No invariant companion is published: discovery derives directly from configuration and credentials without an independent copy.
## Further Exploration
diff --git a/packages/llm/llm-deepseek-api-key/README.zh.md b/packages/llm/llm-deepseek-api-key/README.zh.md
index df6baea123..2f9cd46d0e 100644
--- a/packages/llm/llm-deepseek-api-key/README.zh.md
+++ b/packages/llm/llm-deepseek-api-key/README.zh.md
@@ -25,7 +25,7 @@ kind: "package-reference"
## 使用此包
-`apiKeyEnv` 默认为 `DEEPSEEK_API_KEY`,在每次请求时解析凭据。已组合 credentials 服务时,按其优先级解析;只有未组合该服务时才直接读取启动环境。请求时凭据缺失以 `MISSING_CREDENTIAL` 失败;格式错误以 `INVALID_CREDENTIAL` 失败。模型发现始终返回配置的目录,不依赖凭据。
+`apiKeyEnv` 默认为 `DEEPSEEK_API_KEY`,在每次请求时解析凭据。已组合 credentials 服务时,按其优先级解析;只有未组合该服务时才直接读取启动环境。凭据缺失以 `MISSING_CREDENTIAL` 失败,模型目录为空,因此存入密钥之前路由不提供模型;格式错误以 `INVALID_CREDENTIAL` 失败。
```yaml
- id: llm-deepseek
@@ -42,7 +42,7 @@ kind: "package-reference"
## 理解实现
-注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。无需 invariant companion:目录直接由配置派生,不维护独立副本。
+注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。无需 invariant companion:目录直接由同一次配置和凭据读取派生,不维护独立副本。
## 进一步探索
diff --git a/packages/llm/llm-deepseek-api-key/src/index.ts b/packages/llm/llm-deepseek-api-key/src/index.ts
index cf039f9a0a..ed73d8e09e 100644
--- a/packages/llm/llm-deepseek-api-key/src/index.ts
+++ b/packages/llm/llm-deepseek-api-key/src/index.ts
@@ -39,9 +39,15 @@ export function apply(ctx: Context, config: Config): void {
registerDeepSeekProvider(ctx, PROVIDER, {
options, providerName: 'DeepSeek',
resolveAuth: async connection => ({ headers: { 'x-api-key': await resolveApiKey(connection) } }),
- discoverModels: (provider) => {
+ discoverModels: async (provider) => {
const connection = options()
- return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model)))
+ try { await resolveApiKey(connection) }
+ catch (error) {
+ // A route without its key cannot serve a request, so it offers no models until the key is stored.
+ if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return []
+ throw error
+ }
+ return connection.models.map(model => catalogModelInfo(provider, model))
},
})
}
diff --git a/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts b/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts
index bac1caa441..c09256eb66 100644
--- a/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts
+++ b/packages/llm/llm-deepseek-api-key/tests/discovery.spec.ts
@@ -1,20 +1,38 @@
-/** The configured model catalog is independent of request credentials. */
+/** The official catalog is offered only while its API key resolves. */
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime from '@deepseek-ai/dsh-llm'
-import { expect, it, vi } from 'vitest'
+import { afterEach, expect, it, vi } from 'vitest'
import * as ApiKey from '../src/index.ts'
-it.each(['', 'invalid\nheader'])('advertises configured models without a usable API key: %j', async (key) => {
+const contexts: Context[] = []
+afterEach(async () => {
+ for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+ vi.unstubAllEnvs()
+})
+
+/** Mount the official route with `key` as its launch-environment credential. */
+async function official(key: string): Promise {
vi.stubEnv('DEEPSEEK_API_KEY', key)
const ctx = new Context()
- try {
- await ctx.plugin(LlmRuntime)
- await ctx.plugin(ApiKey, {})
- expect(await ctx.llm.listModels('deepseek-official')).toEqual(expect.arrayContaining([
- expect.objectContaining({ provider: 'deepseek-official', id: 'deepseek-flash' }),
- ]))
- } finally {
- await ctx.fiber.dispose()
- vi.unstubAllEnvs()
- }
+ contexts.push(ctx)
+ await ctx.plugin(LlmRuntime)
+ await ctx.plugin(ApiKey, {})
+ return ctx
+}
+
+it('offers no models while the API key is missing', async () => {
+ const ctx = await official('')
+ expect(await ctx.llm.listModels('deepseek-official')).toEqual([])
+})
+
+it('advertises the configured models once the API key resolves', async () => {
+ const ctx = await official('sk-fixture')
+ expect(await ctx.llm.listModels('deepseek-official')).toEqual(expect.arrayContaining([
+ expect.objectContaining({ provider: 'deepseek-official', id: 'deepseek-flash' }),
+ ]))
+})
+
+it('reports a malformed API key instead of advertising an empty catalog', async () => {
+ const ctx = await official('invalid\nheader')
+ await expect(ctx.llm.listModels('deepseek-official')).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
})
diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml
index b851411448..d658fda134 100644
--- a/packages/llm/llm-deepseek/README.i18n.yaml
+++ b/packages/llm/llm-deepseek/README.i18n.yaml
@@ -39,8 +39,8 @@
en: ec03fc11584fc698
zh: a3f8645b0b817f67
/deepseek-ai-dsh-llm-deepseek/use-this-package/failures-and-recovery:
- en: 1cbfbabee449e756
- zh: 5b13926e079a131e
+ en: 5e3209193e7dc664
+ zh: e7b0facadac1f7fe
/deepseek-ai-dsh-llm-deepseek/understand-the-implementation:
en: 1598ac8a8bb9b6bf
zh: fdd089c5ae7722cd
diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md
index 7ab08ec44b..cb3f0b2b43 100644
--- a/packages/llm/llm-deepseek/README.md
+++ b/packages/llm/llm-deepseek/README.md
@@ -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_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request on the official route without an API key fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key.
-Provider plugins own catalog availability: the API-key route serves its configured `models`, and the account route serves its account listing (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: 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.
-----
diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md
index bce17716fe..4c9a84fa94 100644
--- a/packages/llm/llm-deepseek/README.zh.md
+++ b/packages/llm/llm-deepseek/README.zh.md
@@ -118,7 +118,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。官方路由缺少 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。
-提供方插件负责目录可用性:API key 路由提供配置中的 `models`,账号路由提供账号的模型列表(见[账号提供方](../llm-deepseek-account/README.zh.md))。传输层提供内置目录、目录条目字段的 schema 和能力解析。提供方还可以提供 `connect(signal)`:在提供方自己的工作(例如加载目录)完成后,为精确模型查询和预备调用解析连接;以及 `admit(connection, model)`:在网络请求之前拒绝某个模型。
+提供方插件负责目录可用性:API key 路由在能解析到 API key 时提供配置中的 `models`,账号路由在登录时提供账号的模型列表(见[账号提供方](../llm-deepseek-account/README.zh.md))。传输层提供内置目录、目录条目字段的 schema 和能力解析。提供方还可以提供 `connect(signal)`:在提供方自己的工作(例如加载目录)完成后,为精确模型查询和预备调用解析连接;以及 `admit(connection, model)`:在网络请求之前拒绝某个模型。
-----
diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts
index 86e2664784..e57ccaf424 100644
--- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts
+++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts
@@ -229,17 +229,17 @@ it('cancels an account child without stopping its API-key parent', async () => {
await parent.whenIdle()
})
-it('hides signed-out account models while retaining the official catalog without an API key', async () => {
+it('advertises account and API-key models only while their own credential is available', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
vi.stubGlobal('fetch', vi.fn((input: string | URL | Request) => Promise.resolve(listingRequest(input) ?? new Response(null, { status: 500 }))))
const ctx = await harness()
let token: string | undefined
ctx.provide('deepseekAccount', { resolveToken: async (url: string) => new URL(url).origin === 'https://api.deepseek.com' ? token : undefined } as DeepSeekAccount)
expect(await ctx.llm.listModels('deepseek-account')).toEqual([])
- expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0)
+ expect(await ctx.llm.listModels('deepseek-official')).toEqual([])
token = 'fixture-account-token'
expect(await ctx.llm.listModels('deepseek-account')).not.toHaveLength(0)
- expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0)
+ expect(await ctx.llm.listModels('deepseek-official')).toEqual([])
vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-key')
expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0)
token = undefined
diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts
index 7b324f9a63..d2db2c2a3a 100644
--- a/packages/llm/llm-deepseek/tests/runtime.spec.ts
+++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts
@@ -2170,7 +2170,7 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmRuntime)
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
- await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
+ await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(0)
const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] })
expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
// The guidance leads with the managed credential store.
diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml
index 8afec3d794..1f153fe8b3 100644
--- a/packages/llm/llm-pi-ai/README.i18n.yaml
+++ b/packages/llm/llm-pi-ai/README.i18n.yaml
@@ -39,8 +39,8 @@
en: 6619e8cfe7555c73
zh: b2f80c25acb81cf8
/deepseek-ai-dsh-llm-pi-ai/use-this-package/failures-and-recovery:
- en: 65bcc3b0db3f98e5
- zh: 3c994e74894bea52
+ en: 3c1edbc63de24a1a
+ zh: 01592718c3a2c79f
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation:
en: 1598ac8a8bb9b6bf
zh: fdd089c5ae7722cd
diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md
index 7b1708b957..c69dc0ca52 100644
--- a/packages/llm/llm-pi-ai/README.md
+++ b/packages/llm/llm-pi-ai/README.md
@@ -120,6 +120,8 @@ Config updates strictly validate changed providers. Initial loading retains stor
Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently.
+Model discovery resolves each route’s credential configuration. A missing required credential yields an empty catalog; other resolution errors propagate. Routes with native authentication or no required key retain their configured catalog. This check does not probe the inference endpoint.
+
-----
diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md
index ed780ad235..41b8b7a3e1 100644
--- a/packages/llm/llm-pi-ai/README.zh.md
+++ b/packages/llm/llm-pi-ai/README.zh.md
@@ -120,6 +120,8 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。
+模型发现会解析各路由的凭据配置。缺少必需凭据时返回空目录;其他解析错误继续抛出。使用原生认证或无需密钥的路由保留其配置的目录。此检查不会探测推理端点。
+
-----
diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts
index de9f3e0e42..e20929f1dd 100644
--- a/packages/llm/llm-pi-ai/src/adapter.ts
+++ b/packages/llm/llm-pi-ai/src/adapter.ts
@@ -76,7 +76,8 @@ export interface PiAiAdapterOptions {
profiles: () => ReadonlyMap
/**
* Resolve the credential for one already-resolved profile; called once per
- * stream call and frozen for that call. `undefined` defers to the route's own
+ * stream call and frozen for that call, and by `listModels`, which offers no
+ * models while it throws `MISSING_CREDENTIAL`. `undefined` defers to the route's own
* pi-ai auth, which for an installed catalog route is its provider-native
* ambient discovery; the plugin allows that only for a profile naming no
* credential at all, because a named reference that misses throws `LlmError`
@@ -274,9 +275,15 @@ export class PiAiAdapter extends LlmAdapter {
}
override listModels(provider: string): Promise {
- return Promise.resolve().then(() => {
+ return Promise.resolve().then(async () => {
const snapshot = this.current()
- this.profileOf(snapshot, provider)
+ const profile = this.profileOf(snapshot, provider)
+ try { await this.config.resolveApiKey(provider, profile) }
+ catch (error) {
+ // A route whose named key does not resolve cannot serve a request, so it offers no models until the key is stored.
+ if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return []
+ throw error
+ }
return snapshot.models.getModels(provider).map(model => ({
provider,
id: model.id,
diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts
index 58b8d6afec..0fe90c4e58 100644
--- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts
@@ -1039,11 +1039,13 @@ it.each([
new LlmError('missing credential', 'MISSING_CREDENTIAL'),
new LlmError('invalid credential', 'INVALID_CREDENTIAL'),
new Error('credential storage failed'),
-])('retains its configured catalog independently of credential failures: $message', async (error) => {
+])('distinguishes missing credentials from catalog failures: $message', async (error) => {
const adapter = new PiAiAdapter({
profiles: () => resolveProfiles({ deepseek: { apiKeyEnv: 'PI_TEST_KEY' } }),
resolveApiKey: () => Promise.reject(error),
auth: memoryAuth(),
})
- expect(await adapter.listModels('deepseek')).not.toHaveLength(0)
+ if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') {
+ expect(await adapter.listModels('deepseek')).toEqual([])
+ } else await expect(adapter.listModels('deepseek')).rejects.toBe(error)
})
diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts
index e574461018..155291f594 100644
--- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts
@@ -1126,7 +1126,7 @@ describe('resolution snapshots', () => {
profiles: () => current,
// Credential resolution is the real await inside a stream call, and the
// window a configuration change has to land in.
- resolveApiKey: async () => { await held; return 'k' },
+ resolveApiKey: async (provider) => { if (provider === 'deepseek') await held; return 'k' },
auth: memoryAuth(),
})