fix(config): close the review findings on configuration source ownership

Two had real security consequences:

The bootstrap rejection ran on npm dotenv's parser while process.loadEnvFile
applied the file with Node's own. Two independently maintained dialects meant
the check and the thing it guards could disagree: a name Node accepts but the
checker misses would reach process.env unchecked, and BASH_ENV there runs a
file of the project's choosing on every `bash -c` the bash tool issues. Parse
once with node:util's parseEnv — the same engine loadEnvFile uses — and assign
the entries already checked, which also drops the dotenv dependency.

llm-pi-ai still returned a literal profile.apiKey ahead of everything, and it
registers a settings namespace, so the defect removed from llm-deepseek
survived intact in its design twin. The field is gone from the profile schema,
the resolution path, and the tests.

The rest are consistency and documentation defects the review named:

- verify-config-source-ownership did not scan the Python runtime's bundled
  cordis.yml, which still inlined apiKey and baseURL. Both are covered now, and
  the line-anchored INLINE_DENY documents that it is a tripwire, not a parser.
- The deny list missed NODE_TLS_REJECT_UNAUTHORIZED, the askpass hooks, the
  GIT_CONFIG_* redirections, and PYTHONHOME — all implied by its own stated
  rule about what a variable does.
- Snapshot lookups folded case on Windows, where environment names are
  case-insensitive and an exact-match Map could miss a higher-ranked layer.
- The credentials note claimed a read-time permission check was "not taken"
  while this PR implemented it; the credentials-local README still described
  two layers, live process.env reads, dotenv-era limitations, and a renamed
  anchor; the llm-deepseek README still advertised the removed literal apiKey;
  and web.ts and base.cordis.yml kept personal-overlay wording.
- The ownership note's literal-apiKey claim now names its scope: the
  web-search providers keep a literal field but register no settings
  namespace, so nothing can shadow a stored credential through them.
This commit is contained in:
Yichen Jiang
2026-08-05 11:18:06 +08:00
parent a45aa28ca6
commit 590b76a7f0
33 changed files with 180 additions and 110 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
README.md: 020aa65073495526be3f32912b7cd06667c52a2e
README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb
README.md: b8619268fc264439184ad51d208996ebb3c64e66
README.zh.md: 650185083e5b36bc8508bc3847f87bdf5e1c5678

View File

@@ -15,7 +15,6 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
@@ -53,7 +52,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy.
@@ -112,7 +111,6 @@ Loop-retained response blocks append to the next request and preserve its earlie
## Known Limitations and Deferred Work
- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape.
- **`Config.apiKey` is redacted on the wire but still a stored literal** — `describe({ redactSecrets: true })` strips it and reports the slot, so a configuration UI never receives the value; the key is nonetheless stored in the settings document rather than the credential store, so prefer `apiKeyEnv`.
- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`.

View File

@@ -15,7 +15,6 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
@@ -53,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk:
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。
@@ -77,7 +76,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
## 测试
单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。
单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。
## 模型体验
@@ -112,7 +111,6 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用
## 已知限制与暂缓事项
- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。
- **`Config.apiKey` 在协议上已脱敏,但仍是一个已存的字面值**:`describe({ redactSecrets: true })` 会把它剥离并报告该槽位,配置 UI 因此永远收不到该值;但这个密钥仍存放在 settings 文档而非凭据存储中,所以请优先使用 `apiKeyEnv`。
- **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。
- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。

View File

@@ -109,7 +109,7 @@ describe('request-level dynamic configuration', () => {
it('advertises a live settings catalog without re-registration', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] })
@@ -120,7 +120,7 @@ describe('request-level dynamic configuration', () => {
it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
// Observing the topology event, not just the end state: disposing and
// re-registering also lands on the right final registry, but publishes an
@@ -145,7 +145,7 @@ describe('request-level dynamic configuration', () => {
it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
// Schema-valid but resolver-invalid: duplicate catalog ids pass the array
// schema and fail the explicit resolve step.

View File

@@ -20,8 +20,6 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
export interface PiAiProviderProfile {
/** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
apiKey?: string
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
apiKeyEnv?: string
/** Override the selected catalog model's endpoint without changing its protocol metadata. */
@@ -76,7 +74,6 @@ const thinkingBudgets = z.object({
})
const profile = z.object({
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref'),
baseURL: z.string(),
headers: z.dict(z.string()),
@@ -126,9 +123,6 @@ export function resolveProfiles(
}
if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`)
if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`)
}
if (source.baseURL !== undefined && source.baseURL.length === 0) {
throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
}

View File

@@ -89,7 +89,6 @@ export function apply(ctx: Context, config: Config): void {
provider: string,
profile: ResolvedPiAiProviderProfile,
): Promise<string | undefined> => {
if (profile.apiKey !== undefined) return profile.apiKey
const ref = profile.apiKeyEnv
// Only a profile that names no credential at all defers to pi-ai's
// provider-native discovery. Once one is named, a miss must fail loud:

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
@@ -15,22 +15,32 @@ afterEach(async () => {
})
async function harness(baseURL: string, overrides: Record<string, unknown> = {}): Promise<Context> {
vi.stubEnv('PI_TEST_KEY', 'test-key')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } },
providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL, ...overrides } },
})
return ctx
}
/** Direct adapter over the real profile resolver, with literal-key resolution. */
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
/** Direct adapter over the real profile resolver, with a fixed key per call. */
function adapterOf(
providers: Record<string, LlmPiAi.PiAiProviderProfile>,
apiKey: string | undefined = 'test-key',
): PiAiAdapter {
return new PiAiAdapter({
profiles: () => resolveProfiles(providers),
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
resolveApiKey: () => Promise.resolve(apiKey),
})
}
beforeEach(() => {
// Configuration carries only the reference; these mounts resolve it from
// the environment, which is the whole credential plane without a seam.
vi.stubEnv('PI_TEST_KEY', 'test-key')
})
describe('PiAiAdapter provider routing', () => {
it('resolves a catalog model dynamically and uses a private endpoint', async () => {
const server = await mockServer([{ events: textEvents }])
@@ -117,7 +127,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['deepseek'], adapterOf({
deepseek: { apiKey: 'test-key', baseURL: server.url },
deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: server.url },
}))
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
@@ -146,7 +156,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(result.finish.kind).toBe('error')
@@ -166,7 +176,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
@@ -182,7 +192,7 @@ describe('PiAiAdapter provider routing', () => {
await ctx.plugin(LlmPiAi, {
providers: {
openai: {
apiKey: 'test-key',
apiKeyEnv: 'PI_TEST_KEY',
baseURL: `${server.url}/api/projects/openai/openai/v1`,
headers: { 'api-key': 'test-key', Authorization: '' },
},
@@ -372,7 +382,9 @@ describe('provider profile lifecycle', () => {
it('accepts absent credentials for pi-ai ambient authentication', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, { apiKey: undefined })
// A profile that names no reference at all is the one case that defers to
// pi-ai's own provider-native discovery.
const ctx = await harness(server.url, { apiKeyEnv: undefined })
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
})
@@ -410,8 +422,6 @@ describe('provider profile lifecycle', () => {
// loud with migration directions instead of half-working.
expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/)
expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/)
expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/)
expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/)
expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/)
expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/)
})
@@ -486,7 +496,7 @@ describe('abort wiring', () => {
const message = Object.defineProperty({}, 'role', {
get() { throw original },
})
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const adapter = adapterOf({ deepseek: {} })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek',
@@ -507,7 +517,7 @@ describe('abort wiring', () => {
throw original
},
})
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const adapter = adapterOf({ deepseek: {} })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek',
@@ -521,7 +531,7 @@ describe('abort wiring', () => {
})
it('resolves catalog endpoints without an override before honoring pre-abort', async () => {
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const adapter = adapterOf({ deepseek: {} })
const controller = new AbortController()
controller.abort('already stopped')
const chunks = []

View File

@@ -53,7 +53,11 @@ describe('request-level dynamic profiles', () => {
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n', { mode: 0o600 })
await writeFile(
join(dir, '.credentials.yaml'),
'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
{ mode: 0o600 },
)
const server = await mockServer([{ events: textEvents }])
// The exact product posture: `- id: llm-pi-ai` with no config at all.
const ctx = await boot(dir, {})
@@ -86,14 +90,19 @@ describe('request-level dynamic profiles', () => {
it('adds a provider route from settings and drops it when the user layer resets', async () => {
const dir = await home()
await writeFile(
join(dir, '.credentials.yaml'),
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
{ mode: 0o600 },
)
const server = await mockServer([{ events: textEvents }])
const ctx = await boot(dir, {
providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } },
providers: { openai: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: 'http://127.0.0.1:1/v1' } },
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
await ctx.settings.update(NS, {
providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } },
providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } },
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek'])
@@ -158,15 +167,20 @@ describe('request-level dynamic profiles', () => {
it('keeps serving its routes when a settings-born route collides with another adapter', async () => {
const dir = await home()
await writeFile(
join(dir, '.credentials.yaml'),
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
{ mode: 0o600 },
)
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } })
const ctx = await boot(dir, { providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` } } })
// Another adapter owns `anthropic`; the registry must refuse to hand it over.
ctx.llm.registerAdapter(['anthropic'], new StubAdapter())
await ctx.settings.update(NS, {
providers: {
openai: { apiKey: 'pk', baseURL: `${server.url}/v1` },
anthropic: { apiKey: 'other' },
openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` },
anthropic: { apiKeyEnv: 'PI_OTHER_KEY' },
},
})

View File

@@ -23,7 +23,7 @@ describe('pi-ai SDK retry boundary', () => {
},
})
const adapter = new PiAiAdapter({
profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }),
profiles: () => resolveProfiles({ openai: {} }),
resolveApiKey: () => Promise.resolve('test-key'),
})
const drain = async (): Promise<void> => {