diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 746d1cba35..a27fa14002 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -239,6 +239,18 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const keyLocked = keyState?.writable === false + /** + * The catalog beneath the user layer: what the composition entry pinned, or + * else the schema default that `resolve` would supply. The effective value + * cannot answer this — it still carries the stored override until the unset + * is applied, so reading it would echo that override straight back the + * moment reset drops it, leaving the rows unchanged until a reload. + */ + const inheritedModels = (): unknown => { + const pinned = getPath(namespace.base, [...settingsPath, 'models']) + return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default + } + /** * The curated fields of one known adapter family. Taking the narrowed * family as a parameter is what makes `EFFORT_FIELD` total here: an @@ -248,7 +260,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const effortField = EFFORT_FIELD[family] const customModels = getPath(draft, ['models']) const modelsOverridden = hasPath(draft, ['models']) - const models = modelDrafts(modelsOverridden ? customModels : getPath(fallback, ['models'])) + const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) return ( <> diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 6f080cdddb..d9ebadd1f3 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -41,7 +41,22 @@ const DeepSeekConfig = Schema.object({ name: Schema.string(), description: Schema.string(), contextWindow: Schema.number().step(1).min(1), - })), + // The adapter declares its catalog as a schema default rather than a + // composition entry, which is what the restore-defaults path has to read. + })).default([ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: '', + contextWindow: 1_000_000, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: '', + contextWindow: 1_000_000, + }, + ]), }) const DEFAULT_DEEPSEEK_MODELS = [ @@ -420,6 +435,48 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() }) + it.each([ + ['the schema default', undefined], + ['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }], + ])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => { + // The regression: reset read the EFFECTIVE value, which still carries the + // stored override until the unset is applied — so the rows did not change + // and the catalog only looked restored after reopening the card. + const { face } = scriptedFace() + const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] } + const overridden: SettingsNamespaceView = { + ns: 'llm-deepseek', + schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, + value: { ...stored, defaultContextWindow: 1_000_000 }, + ...base === undefined ? {} : { base }, + user: stored, + applies: 'live', + secrets: [], + revision: 0, + } + const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx') + render( {}} + />) + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByText(en.modelsCustomized)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(['user-only-model']) + + fireEvent.click(screen.getByText(en.resetModels)) + + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment']) + }) + it('renders malformed draft fallbacks without inventing catalog values', () => { render(