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:
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
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -120,6 +120,8 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
|
||||
|
||||
只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。
|
||||
|
||||
模型发现会解析各路由的凭据配置。缺少必需凭据时返回空目录;其他解析错误继续抛出。使用原生认证或无需密钥的路由保留其配置的目录。此检查不会探测推理端点。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user