fix(llm): size unknown models and refuse a section that cannot be served

Three defects surfaced while driving the Models page.

A hand-declared model needed an explicit contextWindow and maxTokens,
but a provider listing usually returns ids and nothing else — so the
page happily wrote a profile the adapter then rejected, which took the
whole namespace down silently. Capacities now fall back to the route's
`defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both
are guesses by construction, which is why they are route fields a
deployment corrects once rather than constants buried in the adapter;
the fallback sizes the model and never becomes a per-request cap.

That silent failure was the second defect. A schema-valid profile the
adapter could not serve was stored and only rejected later, disabling
every route in the namespace with nothing said. `dsh-settings` gains an
optional `validate` on registration — a check for what a schema cannot
express — and `llm-pi-ai` refuses an unserviceable section at the write
that produced it. A stored section that fails keeps the namespace's last
good value, as a schema failure already did, so an externally edited
document still cannot strand the owner. The plugin's own last-good
fallback goes with it: nothing reaching it can fail any more.

Third, a model with no reasoning metadata advertised the single level
`off`, which pi-ai translates to *omitting* the reasoning option — the
same request naming no effort produces. Selecting it disabled nothing,
so a provider whose default is to think kept thinking with `off` shown
as selected. Such a model now reports no reasoning capability at all,
which is the seam's way of saying the control is unavailable, and the
per-model `reasoning` flag is gone: without a thinkingLevelMap to spell
levels it could only invent them.

The protocol table narrows to the three a hand-declared route reaches
today, most-reached first so a surface offering a choice defaults to the
one gateways actually speak.
This commit is contained in:
Yichen Jiang
2026-08-05 18:54:23 +08:00
parent 4c80cab108
commit f376ee23d1
22 changed files with 368 additions and 108 deletions
+46 -6
View File
@@ -44,6 +44,23 @@ export interface SettingsRegisterOptions<T> {
base?: Partial<T>
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
applies?: SettingsApplies
/**
* Reject a resolved section the owner could not act on, for constraints its
* schema cannot express — a cross-field requirement, or one field's validity
* depending on another's. Throwing here refuses the *write* that produced the
* value, so a caller learns at `update`/`replace`/`mutate` instead of storing
* something that would silently disable the owner.
*
* Kept separate from the schema because the schema is also what a
* configuration surface renders and what an absent section resolves through;
* folding a cross-field check into it would change both.
*
* A stored section that fails this keeps the namespace's last good value and
* warns, exactly as a schema failure does, so an externally edited document
* can never strand the owner.
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
}
/** One registered namespace as surfaced to configuration UIs. */
@@ -343,6 +360,8 @@ interface SettingsRegistration {
schema: z<unknown>
base: unknown
applies: SettingsApplies
/** Owner-supplied check for constraints the schema cannot express. */
validate?: (value: unknown) => void
resolved: unknown
/**
* Monotonic counter over this namespace's RAW user section — bumped by any
@@ -456,7 +475,10 @@ export abstract class Settings extends Service {
schema: schema as z<unknown>,
base: options?.base,
applies: options?.applies ?? 'live',
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))),
...options?.validate === undefined
? {}
: { validate: options.validate as (value: unknown) => void },
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)),
revision: 0,
watchers: new Set(),
}
@@ -642,7 +664,7 @@ export abstract class Settings extends Service {
: mode === 'replace'
? snapshot
: (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current)
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate))
await this.persist(ns, section)
// The write reached storage either way; the cache must say so. Commit
// only when this registration is still the namespace owner — a fiber
@@ -684,7 +706,7 @@ export abstract class Settings extends Service {
for (const registration of this.registrations.values()) {
let next: unknown
try {
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns)))
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate))
} catch (error) {
this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns)
this.ctx.logger.warn(error)
@@ -706,10 +728,19 @@ export abstract class Settings extends Service {
}
/** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
private resolve<T>(schema: z<T>, base: unknown, section: Record<string, unknown> | undefined): T {
private resolve<T>(
schema: z<T>,
base: unknown,
section: Record<string, unknown> | undefined,
validate?: (value: T) => void,
): T {
// The merged candidate is untyped by construction; the schema call is the
// runtime validation that admits it into T.
return schema(mergeLayers(base, section) as never)
const value = schema(mergeLayers(base, section) as never)
// The owner's own check runs on the admitted value, so it sees defaults
// and the composition base exactly as the owner will.
validate?.(value)
return value
}
/**
@@ -842,6 +873,12 @@ export interface SettingsSectionHooks<T> {
* memoized resolutions — after an attach, a detach, or a committed change.
*/
onChange(): void
/**
* Reject a resolved section this consumer could not act on, for constraints
* its schema cannot express. See {@link SettingsRegisterOptions.validate}.
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
}
/**
@@ -865,7 +902,10 @@ export function installSettingsSection<T>(
hooks: SettingsSectionHooks<T>,
): void {
ctx.inject(['settings'], (sctx) => {
const scope = sctx.settings.register(ns, schema, { base: entry })
const scope = sctx.settings.register(ns, schema, {
base: entry,
...hooks.validate === undefined ? {} : { validate: hooks.validate },
})
hooks.setSource(() => scope.get())
sctx.effect(() => () => {
// This disposer runs for two different reasons. A settings provider
@@ -95,6 +95,31 @@ describe('registration', () => {
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
})
it('refuses a write its owner could not act on, and keeps the last good value for a stored one', async () => {
const { ctx } = await boot()
const ns = settingsNamespace('ui-theme')
// A constraint the schema cannot express: this owner cannot serve a size
// it considers unreadable, whatever the schema admits.
const scope = ctx.settings.register(ns, ThemeSchema, {
validate: (value) => {
if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`)
},
})
const before = scope.get()
await expect(ctx.settings.update(ns, { fontSize: 4 })).rejects.toThrow(/unreadable/)
expect(scope.get()).toEqual(before)
// An externally edited document must not strand the owner: the namespace
// keeps its last good value, exactly as a schema failure would.
;(ctx.settings as unknown as { publish(doc: Record<string, unknown>): void })
.publish({ 'ui-theme': { fontSize: 4 } })
expect(scope.get()).toEqual(before)
await ctx.settings.update(ns, { fontSize: 18 })
expect(scope.get()).toMatchObject({ fontSize: 18 })
})
it('rejects a duplicate namespace loud', async () => {
const { ctx } = await boot()
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)