feat(llm-pi-ai): dormant bare mount — routes live entirely in the settings plane
An empty or omitted providers dict is now the valid dormant posture: the adapter mounts with zero routes and no catalog entries, registers routes the moment the llm-pi-ai settings section supplies profiles, and drops them when it empties. The TUI demo mounts the adapter bare, so adding an openai/anthropic provider is purely a settings.yaml (or, next PR, web form) operation with per-request apiKeyEnv credential resolution.
This commit is contained in:
@@ -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-pi-ai/README.md
|
||||
README.md: 21e1f6f11777d9de230f26c00046248e111cf0b0
|
||||
README.zh.md: 4f8423bd9a5b812f97a3360218ed1a35a9e58dae
|
||||
README.md: fb8145d58a7c74c70498468044282c740460a947
|
||||
README.zh.md: e49243d81d204ea0567a6930ec99e4fa97f78df4
|
||||
|
||||
@@ -35,7 +35,7 @@ Configure credentials and deployment-specific transport settings per provider, k
|
||||
X-Deployment: production
|
||||
```
|
||||
|
||||
Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
|
||||
Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
|
||||
|
||||
## Dynamic configuration (settings + credentials)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
X-Deployment: production
|
||||
```
|
||||
|
||||
每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
|
||||
每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
|
||||
|
||||
## 动态配置(settings + credentials)
|
||||
|
||||
|
||||
@@ -58,10 +58,14 @@ export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, '
|
||||
retryPolicy: ResolvedRetryPolicy
|
||||
}
|
||||
|
||||
/** Plugin configuration: the non-empty provider routes this instance owns. */
|
||||
/** Plugin configuration: the provider routes this instance owns. */
|
||||
export interface Config {
|
||||
/** Non-empty dict of pi-ai provider routes, keyed by provider. */
|
||||
providers: Record<string, PiAiProviderProfile>
|
||||
/**
|
||||
* pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
|
||||
* the dormant settings-driven posture: the adapter mounts with no routes
|
||||
* and registers them the moment a settings section supplies profiles.
|
||||
*/
|
||||
providers?: Record<string, PiAiProviderProfile>
|
||||
}
|
||||
|
||||
const thinkingBudgets = z.object({
|
||||
@@ -88,21 +92,24 @@ const profile = z.object({
|
||||
|
||||
/** Runtime schema for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
providers: z.dict(profile).required(),
|
||||
providers: z.dict(profile).default({}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate profiles against the installed pi-ai catalog and return a detached
|
||||
* route-keyed map suitable for per-request reads.
|
||||
* route-keyed map suitable for per-request reads. This is the one explicit
|
||||
* resolve step, so an omitted dict resolves to the empty (dormant) route set
|
||||
* here rather than through a hidden fallback.
|
||||
* @param providers - configured provider profiles keyed by route.
|
||||
* @returns validated profiles in configuration order.
|
||||
*/
|
||||
export function resolveProfiles(providers: Readonly<Record<string, PiAiProviderProfile>>): Map<string, ResolvedPiAiProviderProfile> {
|
||||
export function resolveProfiles(
|
||||
providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
|
||||
): Map<string, ResolvedPiAiProviderProfile> {
|
||||
if (Array.isArray(providers)) {
|
||||
throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles')
|
||||
}
|
||||
const entries = Object.entries(providers)
|
||||
if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
|
||||
const entries = Object.entries(providers ?? {})
|
||||
const supported = new Set<string>(getBuiltinProviders())
|
||||
const resolved = new Map<string, ResolvedPiAiProviderProfile>()
|
||||
for (const [provider, source] of entries) {
|
||||
|
||||
@@ -91,19 +91,24 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
const adapter = new PiAiAdapter({ profiles, resolveApiKey })
|
||||
// Route effects bind to this apply fiber via the stable `ctx` reference,
|
||||
// even when a swap runs inside the scoped settings callback below.
|
||||
let disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter)
|
||||
let registeredFacts = registrationFacts(profiles())
|
||||
// even when a swap runs inside the scoped settings callback below. A bare
|
||||
// mount (zero routes) is the dormant posture: nothing registers until a
|
||||
// settings section supplies profiles, and routes drop when it empties.
|
||||
let disposeRoutes: (() => void) | undefined
|
||||
let registeredFacts: unknown
|
||||
const ensureRegistrationFacts = (): void => {
|
||||
const facts = registrationFacts(profiles())
|
||||
if (deepEqualJson(facts, registeredFacts)) return
|
||||
// The registry captures the route set and each route's retry policy at
|
||||
// registration: swap the registration in one synchronous section (same
|
||||
// adapter instance, no NO_ADAPTER window).
|
||||
disposeRoutes()
|
||||
disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter)
|
||||
disposeRoutes?.()
|
||||
disposeRoutes = undefined
|
||||
const routes = [...profiles().keys()]
|
||||
if (routes.length > 0) disposeRoutes = ctx.llm.registerAdapter(routes, adapter)
|
||||
registeredFacts = facts
|
||||
}
|
||||
ensureRegistrationFacts()
|
||||
|
||||
installSettingsSection(ctx, NS, Config, config, {
|
||||
setSource: (source) => {
|
||||
|
||||
@@ -395,7 +395,9 @@ describe('provider profile lifecycle', () => {
|
||||
})
|
||||
|
||||
it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => {
|
||||
expect(() => resolveProfiles({})).toThrow(/at least one/)
|
||||
// Empty and omitted dicts are the dormant zero-route posture, not errors.
|
||||
expect(resolveProfiles({}).size).toBe(0)
|
||||
expect(resolveProfiles(undefined).size).toBe(0)
|
||||
expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/)
|
||||
expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/)
|
||||
// The pre-release array shape and its per-profile provider field fail
|
||||
|
||||
@@ -42,6 +42,30 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
|
||||
}
|
||||
|
||||
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, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
// The exact product posture: `- id: llm-pi-ai` with no config at all.
|
||||
const ctx = await boot(dir, {})
|
||||
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
await ctx.settings.update(NS, {
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
|
||||
})
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0)
|
||||
|
||||
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings')
|
||||
|
||||
// Emptying the user layer returns the adapter to its dormant state.
|
||||
await ctx.settings.replace(NS, {})
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('adds a provider route from settings and drops it when the user layer resets', async () => {
|
||||
const dir = await home()
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
|
||||
Reference in New Issue
Block a user