feat(llm): offer API-key route models only while their key resolves

The official DeepSeek route and pi-ai routes that name an API-key
reference again return an empty catalog while that reference resolves
to nothing, so model pickers stop offering models that can only fail
with MISSING_CREDENTIAL. A malformed key still reports
INVALID_CREDENTIAL, and routes with provider-native authentication keep
their catalogs. This restores the discovery rule that 6db08fd80f
replaced with credential-independent catalogs.
This commit is contained in:
Yichen Jiang committed 2026-09-24 16:48:19 +08:00
1 parent 6d602d792e
commit 94593168a3
16 files changed
+76 -39

No files matched your search

@@ -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
+2 -2
View File
@@ -25,7 +25,7 @@ Authentication resolution returns the validated API key in `x-api-key` for both
<a id="use-this-package"></a>
## 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
<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. 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.
<a id="further-exploration"></a>
## Further Exploration
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用此包
`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"
<a id="understand-the-implementation"></a>
## 理解实现
注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。无需 invariant companion:目录直接由配置派生,不维护独立副本。
注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。无需 invariant companion:目录直接由同一次配置和凭据读取派生,不维护独立副本。
<a id="further-exploration"></a>
## 进一步探索
@@ -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))
},
})
}
@@ -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<Context> {
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' })
})
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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`. 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.
-----
+1 -1
View File
@@ -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`。请求扩展准备、字段冲突或 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)`:在网络请求之前拒绝某个模型。
-----
@@ -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
@@ -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.
+2 -2
View File
@@ -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
+2
View File
@@ -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.
-----
<a id="understand-the-implementation"></a>
+2
View File
@@ -120,6 +120,8 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。
模型发现会解析各路由的凭据配置。缺少必需凭据时返回空目录;其他解析错误继续抛出。使用原生认证或无需密钥的路由保留其配置的目录。此检查不会探测推理端点。
-----
<a id="understand-the-implementation"></a>
+10 -3
View File
@@ -76,7 +76,8 @@ export interface PiAiAdapterOptions {
profiles: () => ReadonlyMap<string, ResolvedPiAiProviderProfile>
/**
* 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<readonly LlmModelInfo[]> {
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,
+4 -2
View File
@@ -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)
})
+1 -1
View File
@@ -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(),
})