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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user