Merge branch 'worktree-llm-dynamic-config' into worktree-llm-web-config

# Conflicts:
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/event-producer-consumer.md
#	examples/headless-agent/tests/headless.snapshot.ts
#	examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl
#	packages/llm/llm-deepseek/README.i18n.yaml
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm-pi-ai/src/index.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/src/index.ts
This commit is contained in:
Yichen Jiang
2026-07-30 17:22:44 +08:00
60 changed files with 1320 additions and 320 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: 186739a3b0ee423afac27ef43c41f42a0e07ee84
README.zh.md: 844fd1422339ad88640a948a735ac1cf9f130ff0
README.md: 11bed74b4952624208f23f093b787eb978cfef69
README.zh.md: 5fa75ad3434fe9610ba8005d436af51fd7a134f9

View File

@@ -50,7 +50,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: 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. 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: 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.
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.

View File

@@ -50,7 +50,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 时——读取原始环境变量。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。

View File

@@ -17,6 +17,7 @@ import type {
ResolvedRetryPolicy,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { serializeRequest } from './serialize.ts'
import type { RequestDefaults } from './serialize.ts'
@@ -45,6 +46,14 @@ export interface DeepSeekCatalogModel {
export interface DeepSeekConnectionOptions {
/** Endpoint base; `/chat/completions` is appended. */
baseURL: string
/**
* Literal API key of this same resolution, when the configuration carried
* one. Travelling with the endpoint is the point: a request can never pair
* one generation's URL with another generation's secret.
*/
apiKey?: string
/** Credential reference of this same resolution, resolved per request when no literal key exists. */
apiKeyEnv: CredentialRef
/** Request defaults applied to every call (thinking mode, effort). */
defaults: RequestDefaults
/** Positive context capacity used when the selected model has no exact value. */
@@ -62,11 +71,12 @@ export interface DeepSeekAdapterOptions {
/** Current validated connection facts; called once per operation. */
options: () => DeepSeekConnectionOptions
/**
* Resolve the bearer token for one request; called once per stream call and
* frozen for that call. Throws `LlmError` `MISSING_CREDENTIAL` when no key
* is available anywhere.
* Resolve the bearer token for the connection facts of one request. The
* snapshot is passed in — never re-read — so the key can only ever come
* from the same resolution as the endpoint it is sent to. Throws `LlmError`
* `MISSING_CREDENTIAL` when no key is available anywhere.
*/
resolveApiKey: () => Promise<string>
resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise<string>
}
/** Default maximum idle interval while an adapter stream read is outstanding. */
@@ -189,8 +199,10 @@ export class DeepSeekAdapter extends LlmAdapter {
// One resolution per stream call: connection facts and the credential
// freeze here and hold for this whole request, so an in-flight stream
// never observes a configuration change and the next call re-resolves.
// The key resolves *from this snapshot*, so an endpoint and the secret
// sent to it can never come from different configuration generations.
const connection = this.config.options()
const apiKey = await this.config.resolveApiKey()
const apiKey = await this.config.resolveApiKey(connection)
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal

View File

@@ -16,7 +16,6 @@ import z from 'schemastery'
import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts'
@@ -32,6 +31,8 @@ export const inject = ['llm']
const NS = settingsNamespace('llm-deepseek')
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
/** The single provider route this plugin owns. */
const PROVIDER = 'deepseek-official'
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 },
@@ -89,11 +90,13 @@ export const Config: z<Config> = z.object({
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
/** Connection facts plus the plugin-consumed credential reference. */
export interface ResolvedDeepSeekOptions extends DeepSeekConnectionOptions {
/** Reference resolved per request when no literal key is configured. */
apiKeyEnv: CredentialRef
}
/**
* One resolution's complete request facts. Connection and credential facts
* are one value on purpose: a snapshot the resolver rejects keeps the whole
* previous generation, so a request can never pair a stale endpoint with a
* newer key.
*/
export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
/** Resolve, validate, and detach the advisory model catalog. */
function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
@@ -147,6 +150,7 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
)
}
return {
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
defaults: {
@@ -187,10 +191,11 @@ export function apply(ctx: Context, config: Config): void {
}
options()
const resolveApiKey = async (): Promise<string> => {
const raw = current()
if (raw.apiKey !== undefined && raw.apiKey.length > 0) return raw.apiKey
const ref = options().apiKeyEnv
const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {
// Every credential fact comes from the caller's snapshot, so a rejected
// settings generation cannot leak its key onto the previous endpoint.
if (connection.apiKey !== undefined) return connection.apiKey
const ref = connection.apiKeyEnv
const credentials = ctx.get('credentials')
if (credentials !== undefined) {
const hit = await credentials.resolve(ref)
@@ -202,19 +207,20 @@ export function apply(ctx: Context, config: Config): void {
if (ambient !== undefined && ambient.length > 0) return ambient
}
throw new LlmError(
'llm-deepseek: no API key for provider route "deepseek-official"; set the llm-deepseek "apiKey" setting,'
+ ` store ${ref} with the credentials service, or export ${ref}`,
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
+ ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a`
+ ' last resort — set a literal "apiKey" in the llm-deepseek settings section',
'MISSING_CREDENTIAL',
)
}
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
ctx.llm.registerConfigurableProviders([
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
{ provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
])
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below.
let disposeRoute = ctx.llm.registerAdapter(['deepseek-official'], adapter)
let disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter)
let registeredPolicy = options().retryPolicy
const ensureRegistrationFacts = (): void => {
const policy = options().retryPolicy
@@ -223,17 +229,10 @@ export function apply(ctx: Context, config: Config): void {
// fact per-request resolution cannot refresh: swap the registration in one
// synchronous section (same adapter instance, no NO_ADAPTER window).
disposeRoute()
disposeRoute = ctx.llm.registerAdapter(['deepseek-official'], adapter)
disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter)
registeredPolicy = policy
}
void resolveApiKey().then(() => undefined, () => {
// Expected on a first boot with dynamic sources: the route stays
// registered (the catalog is browsable) and each request fails with the
// actionable MISSING_CREDENTIAL message until a key arrives.
ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek-official"; requests will fail until one is configured')
})
installSettingsSection(ctx, NS, Config, config, {
setSource: (source) => {
current = source

View File

@@ -824,8 +824,31 @@ describe('plugin registration and config', () => {
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
// The guidance leads with the credential store — the path that keeps the
// secret out of configuration files — and mentions a literal key last.
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY/)
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
})
it('reads the ambient variable when no credentials seam is mounted', async () => {
// The plain cordis.yml composition: no credential provider, the key in
// the launching environment.
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { baseURL: server.url })
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
})
it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
})
it('prefers explicit config over env for key and base URL', async () => {

View File

@@ -143,6 +143,29 @@ describe('request-level dynamic configuration', () => {
])
})
it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
const good = await mockServer([{ kind: 'sse', events: textEvents }])
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url })
// One snapshot moves the endpoint AND the literal key, and fails the
// resolve step beyond the schema (duplicate catalog ids).
await ctx.settings.update(NS, {
apiKey: 'rejected-key',
baseURL: rejected.url,
models: [{ id: 'dup' }, { id: 'dup' }],
})
await prompt(ctx)
// The rejected generation contributes nothing: not its endpoint, and — the
// regression this pins — not its key either.
expect(rejected.requests).toHaveLength(0)
expect(good.requests).toHaveLength(1)
expect(good.headers[0]?.authorization).toBe('Bearer good-key')
})
it('falls back to the composition entry when settings detach', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()

View File

@@ -41,12 +41,15 @@ afterEach(async () => {
})
async function loadComposition(
options: { withDynamic: boolean; baseURL: string },
options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
// A reused root is the restart case: the same harness home, its documents
// exactly as the previous process left them.
const fresh = options.reuseRoot === undefined
root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
const settingsPath = join(root, 'settings.yaml')
const envPath = join(root, '.env')
if (options.withDynamic) {
if (options.withDynamic && fresh) {
await writeFile(settingsPath, '# personal settings\n')
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
}
@@ -129,6 +132,35 @@ describe('llm-deepseek real dynamic composition', () => {
expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key')
})
it('keeps a stored key writable and rotatable across a real restart', async () => {
// No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist
// $DSH_HOME/.env into process.env, so a stored key must stay file-sourced.
vi.stubEnv('DEEPSEEK_API_KEY', '')
const first = await mockServer([{ kind: 'sse', events: textEvents }])
const second = await mockServer([{ kind: 'sse', events: textEvents }])
const boot = await loadComposition({ withDynamic: true, baseURL: first.url })
const home = root!
await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui')
expect(await boot.ctx.get('credentials')!.describe(KEY_REF))
.toEqual({ configured: true, source: 'file', writable: true })
await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui')
await boot.ctx.fiber.dispose()
context = undefined
// Restart over the same harness home.
const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home })
const credentials = restarted.ctx.get('credentials')!
// The stored key is still the provider's own writable file entry — not a
// read-only launch override, which is what hoisting it would have made it.
expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' })
expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true })
// Rotation still works after the restart, and the next request uses it.
await credentials.set(KEY_REF, 'rotated-after-restart')
await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
})
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const server = await mockServer([{ kind: 'sse', events: textEvents }])