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(), })