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:
Yichen Jiang
2026-07-29 14:56:09 +08:00
parent 9336fed1e9
commit 4e9916b3e5
13 changed files with 76 additions and 36 deletions

View File

@@ -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) {

View File

@@ -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) => {