Przeglądaj źródła

feat(ui-models): schema-driven provider configuration page

The Models settings section joins llm.providers (the configurable
directory with live state), settings.describe (schemas, layered redacted
values, secret slots), and credentials.describe (value-free badges) into
provider rows with one editor card at a time. The editor renders the
provider's profile subtree through dsh-client-schema-form; the
credential-ref role mounts a control that shows configured/source state
and stores keys write-only through credentials.set. Apply without
removals merges a minimal patch (stored secrets outside it survive);
apply after a reset — and row deletion — replace the user section so
removals land. The client runtime bridges the three new host frames to
typed ctx events (settings/credentials/models changed), the page
refetches on any of them once loaded, and ui-model's per-session picker
directories reload on models/changed so a settings-born route appears in
open pickers without a reopen.
Yichen Jiang 2 miesięcy temu
rodzic
commit
686e40ebf6

+ 29 - 2
packages/client/runtime/src/client/index.ts

@@ -106,6 +106,28 @@ declare module 'cordis' {
      * @mode emit
      */
     'commands/changed'(): void
+    /**
+     * One settings namespace's resolved value changed on the host
+     * (host/settings-changed passthrough). Subscribers refetch
+     * `settings.describe`; the frame carries no values.
+     * @mode emit
+     * @param ns - the namespace whose resolved value changed.
+     */
+    'settings/changed'(ns: string): void
+    /**
+     * One credential reference's state changed on the host
+     * (host/credentials-changed passthrough). The ref is an
+     * environment-variable NAME — never a value.
+     * @mode emit
+     * @param ref - the reference whose configured state changed.
+     */
+    'credentials/changed'(ref: string): void
+    /**
+     * The host provider topology changed (host/models-changed passthrough).
+     * Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
+     * @mode emit
+     */
+    'models/changed'(): void
     /**
      * A connection generation was (re-)established. Wire-derived caches must
      * treat their state as stale and repull (commands directory; the queue
@@ -144,8 +166,13 @@ export function apply(ctx: Context): void {
       sessions.handleHostEnvelope(envelope)
       workspaces.handleHostEnvelope(envelope)
       // Typed-event bridge: the session layer ignores registry frames (no
-      // session routing); consumers (command directory caches) subscribe on ctx.
-      if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
+      // session routing); consumers (command directory caches, the settings
+      // and model surfaces) subscribe on ctx.
+      const frame = envelope.payload
+      if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
+      else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
+      else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
+      else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
     },
     onConnected: () => {
       sessions.handleConnected()

+ 16 - 0
packages/client/runtime/tests/wire-events.spec.ts

@@ -44,6 +44,22 @@ describe('wire event bridge', () => {
     expect(changed).toBe(1)
   })
 
+  it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
+    const bench = await mount()
+    const seen: unknown[][] = []
+    bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
+    bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
+    bench.ctx.on('models/changed', () => seen.push(['models']))
+    bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
+    bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
+    bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
+    expect(seen).toEqual([
+      ['settings', 'llm-pi-ai'],
+      ['credentials', 'OPENAI_API_KEY'],
+      ['models'],
+    ])
+  })
+
   it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
     const bench = await mount()
     let resets = 0

+ 1 - 1
packages/client/schema-form/src/index.ts

@@ -11,6 +11,6 @@ export type {
   SchemaFieldContext, SchemaFormLabels, SchemaFormProps, SchemaFormSecret,
 } from './SchemaForm.tsx'
 export {
-  deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
+  deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
 } from './model.ts'
 export type { NodeKind, SchemaNode } from './model.ts'

+ 20 - 0
packages/client/schema-form/src/model.ts

@@ -78,6 +78,26 @@ export function unionChoices(node: SchemaNode): unknown[] {
   return (node.list ?? []).map(branch => (branch as { value?: unknown }).value)
 }
 
+/**
+ * Resolve the schema node at a settings path (the configurable-provider
+ * directory's `settingsPath` vocabulary): object properties by name, dict
+ * entries through `inner`. An unresolvable segment returns `undefined` so
+ * the caller falls back instead of rendering a wrong subtree.
+ * @param root - rehydrated section root node.
+ * @param path - key path from the section root.
+ * @returns the node describing that position, or `undefined`.
+ */
+export function nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined {
+  let node: SchemaNode | undefined = root
+  for (const key of path) {
+    if (node === undefined) return undefined
+    if (node.type === 'object') node = (node.dict as Record<string, SchemaNode> | undefined)?.[key]
+    else if (node.type === 'dict' || node.type === 'array') node = node.inner as SchemaNode | undefined
+    else return undefined
+  }
+  return node
+}
+
 /**
  * Read a nested value by path.
  * @param value - root value (draft or fallback layer).

+ 25 - 1
packages/client/schema-form/tests/model.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import Schema from 'schemastery'
 import {
-  deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
+  deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
 } from '../src/model.ts'
 
 const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
@@ -101,3 +101,27 @@ describe('path helpers', () => {
     expect(draft.models[0]).toEqual({ id: 'a', contextWindow: 1 })
   })
 })
+
+describe('nodeAtPath', () => {
+  const Root = Schema.object({
+    providers: Schema.dict(Schema.object({ baseURL: Schema.string() })),
+    models: Schema.array(Schema.object({ id: Schema.string() })),
+    leaf: Schema.string(),
+  })
+
+  it('resolves object, dict, and array positions', () => {
+    const root = rehydrateSchema(Wire(Root))
+    expect(nodeAtPath(root, [])).toBe(root)
+    expect(nodeAtPath(root, ['providers', 'openai'])?.type).toBe('object')
+    expect(nodeAtPath(root, ['providers', 'openai', 'baseURL'])?.type).toBe('string')
+    expect(nodeAtPath(root, ['models', '0', 'id'])?.type).toBe('string')
+    expect(nodeAtPath(root, ['missing'])).toBeUndefined()
+    expect(nodeAtPath(root, ['missing', 'deeper'])).toBeUndefined()
+    expect(nodeAtPath(root, ['leaf', 'below'])).toBeUndefined()
+  })
+
+  it('tolerates structural nodes missing their relation maps', () => {
+    expect(nodeAtPath({ type: 'object' } as never, ['x'])).toBeUndefined()
+    expect(nodeAtPath({ type: 'dict' } as never, ['x'])).toBeUndefined()
+  })
+})

+ 8 - 0
packages/client/ui-model/src/client/service.ts

@@ -44,6 +44,14 @@ export class ModelService extends Service {
     ctx.on('connection/reset', () => {
       for (const directory of this.live.directories.values()) directory.resetConnected()
     })
+    // Provider topology changed on the host (a settings-born route appeared
+    // or dropped): refresh every open directory in the background so pickers
+    // show the new catalog without a reopen. Failures stay on each store.
+    ctx.on('models/changed', () => {
+      for (const directory of this.live.directories.values()) {
+        directory.load().catch(() => undefined)
+      }
+    })
   }
 
   /**

+ 6 - 0
packages/client/ui-models/package.json

@@ -36,17 +36,23 @@
   },
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-connection": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-schema-form": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
+    "@deepseek-ai/dsh-client-web-react": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "cordis": "^4.0.0-rc.7",
     "react": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-schema-form": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web-react": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
     "cordis": "^4.0.0-rc.7",

+ 127 - 0
packages/client/ui-models/src/client/CredentialControl.tsx

@@ -0,0 +1,127 @@
+/**
+ * Credential-reference control: renders the reference NAME as the editable
+ * settings field, its configured state as a badge, and an inline write-only
+ * key input that stores the value through `credentials.set`. The value never
+ * renders back — the wire has no read path for it.
+ */
+
+import { useEffect, useState } from 'react'
+import type { ReactNode } from 'react'
+import type { CredentialView, IApiClient } from '@deepseek-ai/dsh-client-connection/client'
+import type { SchemaFieldContext } from '@deepseek-ai/dsh-client-schema-form'
+import type { en } from './locales.ts'
+import styles from './ModelsSection.module.css'
+
+/** Props of {@link CredentialControl}. */
+export interface CredentialControlProps {
+  /** The `apiKeyEnv` leaf position inside the provider editor's form. */
+  context: SchemaFieldContext
+  /** Credentials wire face. */
+  credentials: IApiClient['credentials']
+  /** Section copy. */
+  t: (key: keyof typeof en) => string
+}
+
+/** The effective reference name this control addresses. */
+function refOf(context: SchemaFieldContext): string | undefined {
+  const value = context.draftValue ?? context.fallbackValue
+  return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+/**
+ * Render the credential-reference field with its live state and key input.
+ * @param props - field context, wire face, and copy.
+ * @returns the control column.
+ */
+export function CredentialControl(props: CredentialControlProps): ReactNode {
+  const { context, credentials, t } = props
+  const ref = refOf(context)
+  const [state, setState] = useState<CredentialView | undefined>(undefined)
+  const [keyDraft, setKeyDraft] = useState('')
+  const [busy, setBusy] = useState(false)
+  const [failure, setFailure] = useState<string | undefined>(undefined)
+
+  useEffect(() => {
+    let stale = false
+    setState(undefined)
+    if (ref === undefined) return undefined
+    void credentials.describe({ refs: [ref] }).then((response) => {
+      if (stale || !response.result.ok) return
+      setState(response.result.value.credentials[ref])
+    })
+    return () => { stale = true }
+  }, [credentials, ref])
+
+  const badge = state === undefined
+    ? null
+    : state.configured
+      ? (
+        <span className={styles['badgeOk']}>
+          {t('credentialConfigured')}
+          {state.source === 'env' ? ` · ${t('credentialFromEnv')}` : ''}
+        </span>
+      )
+      : <span className={styles['badgeWarn']}>{t('credentialMissing')}</span>
+
+  const storeKey = async (): Promise<void> => {
+    /* v8 ignore next -- the save button is disabled while no reference or draft exists */
+    if (ref === undefined || keyDraft.length === 0) return
+    setBusy(true)
+    setFailure(undefined)
+    const response = await credentials.set({ ref, value: keyDraft })
+    setBusy(false)
+    if (!response.result.ok) {
+      setFailure(response.result.error.message)
+      return
+    }
+    setKeyDraft('')
+    const described = await credentials.describe({ refs: [ref] })
+    if (described.result.ok) setState(described.result.value.credentials[ref])
+  }
+
+  return (
+    <div className={styles['credential']}>
+      <div className={styles['credentialRefRow']}>
+        <input
+          className={styles['input']}
+          type="text"
+          value={typeof context.draftValue === 'string' ? context.draftValue : ''}
+          placeholder={typeof context.fallbackValue === 'string' ? context.fallbackValue : undefined}
+          aria-label={t('credentialRef')}
+          disabled={context.disabled}
+          onChange={(event) => {
+            const next = event.target.value
+            if (next === '') context.clearValue()
+            else context.setValue(next)
+          }}
+        />
+        {badge}
+      </div>
+      {ref !== undefined && state?.writable !== false
+        ? (
+          <div className={styles['credentialKeyRow']}>
+            <input
+              className={styles['input']}
+              type="password"
+              autoComplete="off"
+              value={keyDraft}
+              placeholder={t('keyPlaceholder')}
+              disabled={context.disabled || busy}
+              aria-label={t('keyInput')}
+              onChange={(event) => { setKeyDraft(event.target.value) }}
+            />
+            <button
+              type="button"
+              className={styles['secondaryButton']}
+              disabled={context.disabled || busy || keyDraft.length === 0}
+              onClick={() => { void storeKey() }}
+            >
+              {t('keySave')}
+            </button>
+          </div>
+        )
+        : null}
+      {failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
+    </div>
+  )
+}

+ 188 - 0
packages/client/ui-models/src/client/ModelsSection.module.css

@@ -0,0 +1,188 @@
+.section {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  max-width: 720px;
+}
+
+.title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+
+.intro {
+  margin: 0;
+  font-size: 13px;
+  color: var(--text-tertiary, #888);
+}
+
+.notice {
+  margin: 0;
+  font-size: 12px;
+  color: var(--text-warning, #a15c00);
+}
+
+.rows {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.rowCard {
+  border: 1px solid var(--border, #e2e2e2);
+  border-radius: 12px;
+  padding: 12px 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  background: var(--surface, #fff);
+}
+
+.rowHead {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.rowName {
+  font-size: 15px;
+  font-weight: 600;
+}
+
+.badges {
+  display: inline-flex;
+  gap: 6px;
+  flex: 1;
+}
+
+.badgeOk {
+  color: var(--text-success, #0a7d33);
+  font-size: 12px;
+}
+
+.badgeMuted {
+  color: var(--text-tertiary, #999);
+  font-size: 12px;
+}
+
+.badgeWarn {
+  color: var(--text-warning, #a15c00);
+  font-size: 12px;
+}
+
+.rowActions {
+  display: inline-flex;
+  gap: 8px;
+}
+
+.primaryButton {
+  border: none;
+  border-radius: 999px;
+  padding: 8px 18px;
+  background: var(--accent-strong, #111);
+  color: var(--text-inverse, #fff);
+  font: inherit;
+  cursor: pointer;
+}
+
+.secondaryButton {
+  border: 1px solid var(--border, #d9d9d9);
+  border-radius: 999px;
+  padding: 6px 14px;
+  background: var(--surface, #fff);
+  color: inherit;
+  font: inherit;
+  cursor: pointer;
+}
+
+.dangerButton {
+  border: none;
+  background: none;
+  color: var(--text-danger, #c0392b);
+  font: inherit;
+  cursor: pointer;
+}
+
+.primaryButton:disabled,
+.secondaryButton:disabled,
+.dangerButton:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
+
+.editor {
+  border-top: 1px solid var(--border, #eee);
+  padding-top: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.editorHeader {
+  display: flex;
+  align-items: center;
+}
+
+.editorTitle {
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.editorActions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+}
+
+.addBlock {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.addSelect {
+  align-self: flex-start;
+  border: 1px solid var(--border, #d9d9d9);
+  border-radius: 999px;
+  padding: 8px 14px;
+  font: inherit;
+  background: var(--surface, #fff);
+}
+
+.credential {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.credentialRefRow,
+.credentialKeyRow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.credentialRefRow > input,
+.credentialKeyRow > input {
+  flex: 1;
+}
+
+.input {
+  box-sizing: border-box;
+  padding: 8px 10px;
+  border: 1px solid var(--border, #d9d9d9);
+  border-radius: 8px;
+  font: inherit;
+  background: var(--surface, #fff);
+  color: inherit;
+}
+
+.error {
+  margin: 0;
+  font-size: 12px;
+  color: var(--text-danger, #c0392b);
+}

+ 212 - 7
packages/client/ui-models/src/client/ModelsSection.tsx

@@ -1,13 +1,218 @@
 /**
- * Models settings section: an intentionally empty content column — the nav
- * entry exists so the section slot composition is visible; model management
- * lands in a later phase.
+ * Models settings section: the provider rows joined from the configurable
+ * directory, settings namespaces, and credential states, with one editor
+ * card at a time (edit an existing provider or add a dormant one). Every
+ * mutation writes through the wire; the page re-renders from the pushed
+ * invalidations or the post-apply reload.
  */
 
+import { useState } from 'react'
+import type { ReactNode } from 'react'
+import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
+import { deletePath } from '@deepseek-ai/dsh-client-schema-form'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
+import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
+import { ProviderEditor } from './ProviderEditor.tsx'
+import type { en } from './locales.ts'
+import styles from './ModelsSection.module.css'
+
+/** Injected dependencies of {@link ModelsSection} (slot `inject`). */
+export interface ModelsSectionInjected {
+  /** The page store (loaded on mount, refreshed on pushed invalidations). */
+  controller: ModelsSettingsStore
+  /** uSES subscription hook bound to the store. */
+  useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
+  /** Wire faces the editor and credential control write through. */
+  api: Pick<IApiClient, 'settings' | 'credentials'>
+  /** Section copy. */
+  t: (key: keyof typeof en) => string
+}
+
+/** Props delivered by the slot outlet. */
+export interface ModelsSectionProps {
+  injected?: ModelsSectionInjected
+}
+
+/** The editor target: an existing row or a dormant directory entry. */
+interface EditorTarget {
+  provider: string
+  settingsNs: string
+  settingsPath: readonly string[]
+}
+
 /**
- * Render the (empty) Models section content column.
- * @returns null — no content this phase.
+ * Remove one user-added provider profile from its namespace's user section
+ * (wholesale replace — merge cannot express a removal) and reload on success.
+ * @param api - settings wire face.
+ * @param controller - the page store to refresh.
+ * @param target - the provider's settings address.
+ * @param namespace - the owning namespace view.
+ * @returns settles when the write and any reload finished.
  */
-export function ModelsSection() {
-  return null
+export async function removeProviderProfile(
+  api: Pick<IApiClient, 'settings'>,
+  controller: ModelsSettingsStore,
+  target: { settingsNs: string; settingsPath: readonly string[] },
+  namespace: SettingsNamespaceView,
+): Promise<void> {
+  const user = structuredClone((namespace.user ?? {}) as Record<string, unknown>)
+  const next = deletePath(user, [...target.settingsPath])
+  const response = await api.settings.replace({ ns: target.settingsNs, section: next })
+  if (response.result.ok) await controller.load()
+}
+
+function StatusBadges({ row, t }: { row: ProviderRow; t: ModelsSectionInjected['t'] }): ReactNode {
+  return (
+    <span className={styles['badges']}>
+      {row.entry.active
+        ? <span className={styles['badgeOk']}>{t('active')}</span>
+        : <span className={styles['badgeMuted']}>{t('dormant')}</span>}
+      {row.credential !== undefined && !row.credential.configured
+        ? <span className={styles['badgeWarn']}>{t('keyMissing')}</span>
+        : null}
+    </span>
+  )
+}
+
+/**
+ * Render the Models section content column.
+ * @param props - slot-delivered injected dependencies.
+ * @returns the section, or null while the shell has not injected yet.
+ */
+export function ModelsSection(props: ModelsSectionProps): ReactNode {
+  const injected = props.injected
+  if (injected === undefined) return null
+  return <Loaded injected={injected} />
+}
+
+function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
+  const { controller, api, t } = injected
+  const state = injected.useSnapshot(snapshot => snapshot)
+  const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
+  const [adding, setAdding] = useState(false)
+
+  const closeEditor = (changed: boolean): void => {
+    setEditing(undefined)
+    setAdding(false)
+    if (changed) void controller.load()
+  }
+
+  if (state.status === 'idle') void controller.load()
+  if (state.status === 'error') {
+    /* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
+    const errorText = state.error ?? ''
+    return (
+      <div className={styles['section']}>
+        <p className={styles['error']}>{`${t('loadFailed')}: ${errorText}`}</p>
+        <button type="button" className={styles['secondaryButton']} onClick={() => { void controller.load() }}>
+          {t('retry')}
+        </button>
+      </div>
+    )
+  }
+
+  const configured = state.rows.filter(row => row.configured)
+  const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
+  const addTarget = adding ? editing : undefined
+  const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
+
+  return (
+    <div className={styles['section']}>
+      <h2 className={styles['title']}>{t('title')}</h2>
+      <p className={styles['intro']}>{t('intro')}</p>
+      {!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
+      <ul className={styles['rows']}>
+        {configured.map((row) => {
+          const target: EditorTarget = {
+            provider: row.entry.provider,
+            settingsNs: row.entry.settingsNs,
+            settingsPath: row.entry.settingsPath,
+          }
+          const open = !adding && editing?.provider === row.entry.provider
+          const namespace = state.namespaces.get(target.settingsNs)
+          /* v8 ignore next -- the join marks a row configured only when its namespace resolved */
+          if (namespace === undefined) return null
+          return (
+            <li key={row.entry.provider} className={styles['rowCard']}>
+              <div className={styles['rowHead']}>
+                <span className={styles['rowName']}>{row.entry.displayName}</span>
+                <StatusBadges row={row} t={t} />
+                <span className={styles['rowActions']}>
+                  <button
+                    type="button"
+                    className={styles['secondaryButton']}
+                    onClick={() => { setAdding(false); setEditing(open ? undefined : target) }}
+                  >
+                    {t('edit')}
+                  </button>
+                  {row.removable
+                    ? (
+                      <button
+                        type="button"
+                        className={styles['dangerButton']}
+                        disabled={!state.writable}
+                        onClick={() => { void removeProviderProfile(api, controller, target, namespace) }}
+                      >
+                        {t('remove')}
+                      </button>
+                    )
+                    : null}
+                </span>
+              </div>
+              {open
+                ? (
+                  <ProviderEditor
+                    provider={target.provider}
+                    namespace={namespace}
+                    settingsPath={target.settingsPath}
+                    api={api}
+                    t={t}
+                    readOnly={!state.writable}
+                    onClose={closeEditor}
+                  />
+                )
+                : null}
+            </li>
+          )
+        })}
+      </ul>
+      <div className={styles['addBlock']}>
+        {addTarget !== undefined && addNamespace !== undefined
+          ? (
+            <ProviderEditor
+              provider={addTarget.provider}
+              namespace={addNamespace}
+              settingsPath={addTarget.settingsPath}
+              api={api}
+              t={t}
+              readOnly={!state.writable}
+              onClose={closeEditor}
+            />
+          )
+          : (
+            <select
+              className={styles['addSelect']}
+              value=""
+              disabled={addable.length === 0 || !state.writable}
+              aria-label={t('add')}
+              onChange={(event) => {
+                const row = addable.find(candidate => candidate.entry.provider === event.target.value)
+                if (row === undefined) return
+                setAdding(true)
+                setEditing({
+                  provider: row.entry.provider,
+                  settingsNs: row.entry.settingsNs,
+                  settingsPath: row.entry.settingsPath,
+                })
+              }}
+            >
+              <option value="">{`+ ${t('add')}`}</option>
+              {addable.map(row => (
+                <option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
+              ))}
+            </select>
+          )}
+      </div>
+    </div>
+  )
 }

+ 168 - 0
packages/client/ui-models/src/client/ProviderEditor.tsx

@@ -0,0 +1,168 @@
+/**
+ * One provider's editor card: the schema-driven form over its profile
+ * subtree, the credential-reference control, and the Apply/Cancel pair.
+ * Apply without removals merges (`settings.update`, preserving stored keys
+ * outside the patch); apply after a field reset replaces the user section so
+ * the reset actually lands.
+ */
+
+import { useMemo, useState } from 'react'
+import type { ReactNode } from 'react'
+import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
+import {
+  getPath, nodeAtPath, rehydrateSchema, SchemaForm, setPath, validateDraft,
+} from '@deepseek-ai/dsh-client-schema-form'
+import type { SchemaFormSecret } from '@deepseek-ai/dsh-client-schema-form'
+import { CredentialControl } from './CredentialControl.tsx'
+import type { en } from './locales.ts'
+import styles from './ModelsSection.module.css'
+
+/** Props of {@link ProviderEditor}. */
+export interface ProviderEditorProps {
+  /** Provider route id (card title). */
+  provider: string
+  /** The owning namespace view (schema, layers, secrets). */
+  namespace: SettingsNamespaceView
+  /** Path from the section root to this provider's profile. */
+  settingsPath: readonly string[]
+  /** Wire faces for writes. */
+  api: Pick<IApiClient, 'settings' | 'credentials'>
+  /** Section copy. */
+  t: (key: keyof typeof en) => string
+  /** Disable writes (read-only settings provider). */
+  readOnly: boolean
+  /** Close the editor; `changed` reports whether an Apply committed. */
+  onClose: (changed: boolean) => void
+}
+
+/** Secrets re-rooted at the profile subtree (paths relative to the editor's form). */
+function secretsUnder(namespace: SettingsNamespaceView, path: readonly string[]): SchemaFormSecret[] {
+  return namespace.secrets.flatMap((secret) => {
+    if (secret.path.length < path.length) return []
+    if (!path.every((key, index) => secret.path[index] === key)) return []
+    return [{ path: secret.path.slice(path.length), set: secret.set }]
+  })
+}
+
+/** A user-section subtree as a plain draft object (absent → empty). */
+function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Record<string, unknown> {
+  const subtree = getPath(namespace.user, path)
+  if (typeof subtree !== 'object' || subtree === null || Array.isArray(subtree)) return {}
+  return structuredClone(subtree) as Record<string, unknown>
+}
+
+/** Whether any key present in `before` is absent from `after` (a reset happened). */
+function removedAny(before: unknown, after: unknown): boolean {
+  if (typeof before !== 'object' || before === null) return false
+  /* v8 ignore next -- the form edits containers in place; a container cannot become a primitive */
+  if (typeof after !== 'object' || after === null) return true
+  for (const [key, value] of Object.entries(before)) {
+    if (!(key in (after as Record<string, unknown>))) return true
+    if (removedAny(value, (after as Record<string, unknown>)[key])) return true
+  }
+  return false
+}
+
+/**
+ * Render one provider's editing card.
+ * @param props - the addressed profile plus wire faces and copy.
+ * @returns the editor card.
+ */
+export function ProviderEditor(props: ProviderEditorProps): ReactNode {
+  const { namespace, settingsPath, api, t } = props
+  const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(namespace, settingsPath))
+  const [busy, setBusy] = useState(false)
+  const [failure, setFailure] = useState<string | undefined>(undefined)
+  const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
+  const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
+  const subtreeSchema = useMemo(() => node?.toJSON(), [node])
+  const fallback = getPath(namespace.value, settingsPath)
+  const secrets = useMemo(() => secretsUnder(namespace, settingsPath), [namespace, settingsPath])
+
+  const apply = async (): Promise<void> => {
+    setBusy(true)
+    setFailure(undefined)
+    const ns = namespace.ns
+    const original = getPath(namespace.user, settingsPath)
+    const needsReplace = removedAny(original, draft)
+    // Merge patches stay minimal (just this profile); a replace must carry
+    // the complete next user section because it lands wholesale.
+    const patch = settingsPath.length === 0 ? draft : setPath({}, [...settingsPath], draft)
+    /* v8 ignore next 3 -- a subtree apply implies the join served this namespace's user layer */
+    const nextSection = settingsPath.length === 0
+      ? draft
+      : setPath(structuredClone((namespace.user ?? {}) as Record<string, unknown>), [...settingsPath], draft)
+    /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
+    if (node !== undefined) {
+      const sectionError = settingsPath.length === 0 ? validateDraft(node, draft) : undefined
+      if (sectionError !== undefined) {
+        setBusy(false)
+        setFailure(sectionError)
+        return
+      }
+    }
+    const response = needsReplace
+      ? await api.settings.replace({ ns, section: nextSection })
+      : await api.settings.update({ ns, patch })
+    setBusy(false)
+    if (!response.result.ok) {
+      setFailure(response.result.error.message)
+      return
+    }
+    props.onClose(true)
+  }
+
+  if (node === undefined || subtreeSchema === undefined) {
+    // A directory entry addressing a position its schema cannot resolve is a
+    // host-side inconsistency; showing it beats a blank card.
+    return <p className={styles['error']}>{`${props.provider}: unresolvable settings path`}</p>
+  }
+
+  return (
+    <div className={styles['editor']}>
+      <div className={styles['editorHeader']}>
+        <span className={styles['editorTitle']}>{props.provider}</span>
+      </div>
+      <SchemaForm
+        schema={subtreeSchema}
+        draft={draft}
+        fallback={fallback}
+        secrets={secrets}
+        disabled={props.readOnly || busy}
+        onChange={setDraft}
+        labels={{
+          reset: t('reset'),
+          add: t('addLabel'),
+          remove: t('removeLabel'),
+          secretSet: t('secretSet'),
+          secretUnset: t('secretUnset'),
+          inherited: t('inherited'),
+          unsupported: t('unsupported'),
+        }}
+        renderField={(context) => {
+          if (context.role !== 'credential-ref') return undefined
+          return <CredentialControl context={context} credentials={api.credentials} t={t} />
+        }}
+      />
+      {failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
+      <div className={styles['editorActions']}>
+        <button
+          type="button"
+          className={styles['secondaryButton']}
+          disabled={busy}
+          onClick={() => { props.onClose(false) }}
+        >
+          {t('cancel')}
+        </button>
+        <button
+          type="button"
+          className={styles['primaryButton']}
+          disabled={props.readOnly || busy}
+          onClick={() => { void apply() }}
+        >
+          {busy ? t('applying') : t('apply')}
+        </button>
+      </div>
+    </div>
+  )
+}

+ 55 - 9
packages/client/ui-models/src/client/index.ts

@@ -1,44 +1,90 @@
 /**
  * Models settings section plugin, browser half. Registers the `models` nav
- * entry into the shell-declared `settings.section` list slot; the content
- * column is intentionally empty until model management lands. Export
- * discipline: packages/client/AGENTS.md.
+ * entry into the shell-declared `settings.section` list slot and mounts the
+ * provider configuration page: the configurable-provider directory joined
+ * with settings namespaces and credential states, edited through the
+ * schema-driven form. Export discipline: packages/client/AGENTS.md.
  */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
 import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import { ModelsSection } from './ModelsSection.tsx'
+import type { ModelsSectionInjected } from './ModelsSection.tsx'
+import { ModelsSettingsStore } from './store.ts'
+import { en, zh } from './locales.ts'
+
+export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
+export type { ModelsSettingsState, ProviderRow } from './store.ts'
+
+/**
+ * Refetch the page snapshot only after its first load: an unopened Models
+ * page must not fetch on background invalidations.
+ * @param controller - the page store.
+ */
+export function refreshIfLoaded(controller: ModelsSettingsStore): void {
+  if (controller.store.getSnapshot().status === 'idle') return
+  void controller.load()
+}
 
 /**
  * Required services (cordis fiber inject). The target slot is declared by
  * ui-settings' apply, whose activation order relative to this one is NOT
  * constrained; registration goes through declaration-aware deferral.
  */
-export const inject = ['slots', 'locale']
+export const inject = ['slots', 'locale', 'connection']
 
 /**
  * Register the Models section once the `settings.section` declaration is on
- * the ledger.
+ * the ledger, wire its store to the connection, and keep it fresh on every
+ * pushed invalidation (settings, credentials, or provider topology).
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => {
     const disposers = [
-      ctx.locale.register('settings.models', 'zh', { nav: '模型' }),
-      ctx.locale.register('settings.models', 'en', { nav: 'Models' }),
+      ctx.locale.register('settings.models', 'zh', zh),
+      ctx.locale.register('settings.models', 'en', en),
+    ]
+    return () => { for (const dispose of disposers) dispose() }
+  }, 'ui-models: copy dictionaries')
+
+  const connection = ctx.get('connection') as ConnectionHandle
+  const controller = new ModelsSettingsStore(connection.api)
+  const useSnapshot = bindSnapshotSelector(controller.store)
+  const t = ctx.locale.bind('settings.models') as ModelsSectionInjected['t']
+  const injected = (): ModelsSectionInjected => ({
+    controller,
+    useSnapshot,
+    api: connection.api,
+    t,
+  })
+
+  // Pushed invalidations converge every open surface without polling: any
+  // settings/credentials/topology change refetches once the page loaded.
+  ctx.effect(() => {
+    const refresh = (): void => { refreshIfLoaded(controller) }
+    const disposers = [
+      ctx.on('settings/changed', refresh),
+      ctx.on('credentials/changed', refresh),
+      ctx.on('models/changed', refresh),
+      ctx.on('connection/reset', refresh),
     ]
     return () => { for (const dispose of disposers) dispose() }
-  }, 'ui-models: nav copy dictionaries')
+  }, 'ui-models: pushed invalidations')
+
   ctx.effect(() => {
     const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
       ctx.slots.register({
         name: 'settings.section',
         id: 'models',
         order: 10,
-        label: ctx.locale.bind('settings.models')('nav'),
+        label: t('nav'),
+        inject: injected,
       }, ModelsSection))
     // Nav labels are registrant-localized: refresh on locale change so the
     // ledger carries fresh text (the version bump re-renders the shell).

+ 71 - 0
packages/client/ui-models/src/client/locales.ts

@@ -0,0 +1,71 @@
+/** Copy dictionaries for the Models settings section. */
+
+/** English strings. */
+export const en = {
+  nav: 'Models',
+  title: 'Models',
+  intro: 'Enter your API keys to use models from the following providers.',
+  active: 'Active',
+  dormant: 'Inactive',
+  keyMissing: 'No API key',
+  edit: 'Edit',
+  remove: 'Delete',
+  add: 'Add provider',
+  provider: 'Provider',
+  cancel: 'Cancel',
+  apply: 'Apply',
+  applying: 'Applying…',
+  readOnly: 'The settings document is read-only in this deployment.',
+  loadFailed: 'Loading the provider directory failed',
+  retry: 'Retry',
+  credentialRef: 'API key environment variable',
+  credentialConfigured: 'Configured',
+  credentialFromEnv: 'from the launch environment (read-only)',
+  credentialMissing: 'Not configured',
+  keyInput: 'API key',
+  keyPlaceholder: 'Enter a key to store it',
+  keySave: 'Save key',
+  keyClear: 'Clear key',
+  reset: 'Reset',
+  addLabel: 'Add',
+  removeLabel: 'Remove',
+  secretSet: 'Configured — enter a new value to replace',
+  secretUnset: 'Not configured',
+  inherited: 'Default',
+  unsupported: 'This field has no form control; edit the settings document directly.',
+}
+
+/** Chinese strings (same keys as {@link en}). */
+export const zh: typeof en = {
+  nav: '模型',
+  title: '模型',
+  intro: '填入各提供方的 API 密钥即可使用其模型。',
+  active: '已启用',
+  dormant: '未启用',
+  keyMissing: '缺少密钥',
+  edit: '编辑',
+  remove: '删除',
+  add: '添加提供方',
+  provider: '提供方',
+  cancel: '取消',
+  apply: '保存',
+  applying: '保存中…',
+  readOnly: '当前部署的设置文档为只读。',
+  loadFailed: '加载提供方目录失败',
+  retry: '重试',
+  credentialRef: 'API 密钥环境变量',
+  credentialConfigured: '已配置',
+  credentialFromEnv: '来自启动环境(只读)',
+  credentialMissing: '未配置',
+  keyInput: 'API 密钥',
+  keyPlaceholder: '输入密钥以保存',
+  keySave: '保存密钥',
+  keyClear: '清除密钥',
+  reset: '重置',
+  addLabel: '添加',
+  removeLabel: '移除',
+  secretSet: '已设置——输入新值可替换',
+  secretUnset: '未设置',
+  inherited: '默认',
+  unsupported: '该字段没有对应表单控件;请直接编辑设置文档。',
+}

+ 136 - 0
packages/client/ui-models/src/client/store.ts

@@ -0,0 +1,136 @@
+/**
+ * Models settings page store: one snapshot joining the configurable-provider
+ * directory (`llm.providers`), the settings namespaces (`settings.describe`),
+ * and the referenced credentials (`credentials.describe`). The host stays the
+ * single fact source — every mutation writes through the wire and the page
+ * re-renders from the next describe, pushed or refetched.
+ */
+
+import type {
+  ConfigurableProviderView, CredentialView, IApiClient, SettingsNamespaceView,
+} from '@deepseek-ai/dsh-client-connection/client'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import { getPath, hasPath } from '@deepseek-ai/dsh-client-schema-form'
+
+/** One provider row the page renders. */
+export interface ProviderRow {
+  /** The directory entry (route id, display name, settings address, live state). */
+  entry: ConfigurableProviderView
+  /** Whether any layer configures this provider (its profile resolves). */
+  configured: boolean
+  /** Whether the user layer alone carries the profile (removal restores the base). */
+  removable: boolean
+  /** The credential reference the resolved profile names, when one does. */
+  apiKeyEnv: string | undefined
+  /** Credential state for {@link apiKeyEnv}, once described. */
+  credential: CredentialView | undefined
+}
+
+/** Page snapshot. */
+export interface ModelsSettingsState {
+  status: 'idle' | 'loading' | 'ready' | 'error'
+  /** Whole-load failure text; row-level write failures stay in the editor. */
+  error: string | null
+  /** Whether the settings provider accepts writes. */
+  writable: boolean
+  /** Every configurable provider joined with its configured/credential state. */
+  rows: readonly ProviderRow[]
+  /** Namespace views by ns, for the editor's schema/layers/secrets. */
+  namespaces: ReadonlyMap<string, SettingsNamespaceView>
+}
+
+/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
+function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined {
+  if (namespace === undefined) return undefined
+  const profile = getPath(namespace.value, path)
+  if (typeof profile !== 'object' || profile === null) return undefined
+  const ref = (profile as { apiKeyEnv?: unknown }).apiKeyEnv
+  return typeof ref === 'string' && ref.length > 0 ? ref : undefined
+}
+
+/** The models settings page controller (one per settings surface). */
+export class ModelsSettingsStore {
+  /** The snapshot the section renders from (uSES-safe store). */
+  readonly store: SnapshotStore<ModelsSettingsState> = createSnapshotStore<ModelsSettingsState>({
+    status: 'idle', error: null, writable: false, rows: [], namespaces: new Map(),
+  })
+
+  /** Latest load wins; an older response never overwrites a newer one. */
+  private generation = 0
+
+  /**
+   * @param api - the wire face (settings/credentials/llm domains).
+   */
+  constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
+
+  /**
+   * Refresh the whole page snapshot: directory and namespaces in parallel,
+   * then one batched credential describe over every referenced ref. A
+   * failure keeps the last good rows and surfaces the error.
+   * @returns nothing; the snapshot carries the outcome.
+   */
+  async load(): Promise<void> {
+    const generation = ++this.generation
+    this.store.update((s) => { s.status = 'loading'; s.error = null })
+    let providers: ConfigurableProviderView[]
+    let writable: boolean
+    let views: SettingsNamespaceView[]
+    try {
+      const [providersResponse, settingsResponse] = await Promise.all([
+        this.api.llm.providers({}),
+        this.api.settings.describe({}),
+      ])
+      if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message)
+      if (!settingsResponse.result.ok) throw new Error(settingsResponse.result.error.message)
+      providers = providersResponse.result.value.providers
+      writable = settingsResponse.result.value.writable
+      views = settingsResponse.result.value.namespaces
+    } catch (error) {
+      if (generation !== this.generation) return
+      this.store.update((s) => {
+        s.status = 'error'
+        s.error = error instanceof Error ? error.message : String(error)
+      })
+      return
+    }
+    const namespaces = new Map(views.map(view => [view.ns, view]))
+    const rows: ProviderRow[] = providers.map((entry) => {
+      const namespace = namespaces.get(entry.settingsNs)
+      const configured = namespace !== undefined
+        && (entry.settingsPath.length === 0 || getPath(namespace.value, entry.settingsPath) !== undefined)
+      const removable = namespace !== undefined
+        && entry.settingsPath.length > 0
+        && hasPath(namespace.user, entry.settingsPath)
+        && !hasPath(namespace.base, entry.settingsPath)
+      return {
+        entry,
+        configured,
+        removable,
+        apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
+        credential: undefined,
+      }
+    })
+    const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
+    let credentials: Record<string, CredentialView> = {}
+    if (refs.length > 0) {
+      const response = await this.api.credentials.describe({ refs })
+      // Credential state is an enrichment: rows render without it, so a
+      // missing credential provider degrades the badge, not the page.
+      if (response.result.ok) credentials = response.result.value.credentials
+    }
+    if (generation !== this.generation) return
+    this.store.update((s) => {
+      s.status = 'ready'
+      s.error = null
+      s.writable = writable
+      s.rows = rows.map(row => ({
+        ...row,
+        ...row.apiKeyEnv !== undefined && credentials[row.apiKeyEnv] !== undefined
+          ? { credential: credentials[row.apiKeyEnv] }
+          : {},
+      }))
+      s.namespaces = namespaces
+    })
+  }
+}

+ 40 - 3
packages/client/ui-models/tests/apply.spec.ts

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
 import { describe, expect, it } from 'vitest'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
-import { apply, inject } from '@deepseek-ai/dsh-client-ui-models/client'
+import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
 import { ModelsSection } from '../src/client/ModelsSection.tsx'
 
 async function bench() {
@@ -11,6 +11,9 @@ async function bench() {
   await ctx.plugin(SlotsService).await()
   const locale = new LocaleService(ctx)
   ctx.provide('locale', locale)
+  // The apply path only captures the wire face; no call leaves this fake
+  // until a section actually loads.
+  ctx.provide('connection', { api: {} } as never)
   return { ctx, slots: ctx.get('slots') as SlotsService, locale }
 }
 
@@ -23,7 +26,7 @@ function declare(slots: SlotsService): () => void {
 
 describe('ui-models apply', () => {
   it('declares the services it uses', () => {
-    expect(inject).toEqual(['slots', 'locale'])
+    expect(inject).toEqual(['slots', 'locale', 'connection'])
   })
 
   it('registers the models nav entry for declarations before or after apply', async () => {
@@ -32,7 +35,12 @@ describe('ui-models apply', () => {
     await before.ctx.plugin({ inject: [...inject], apply }).await()
     const entry = before.slots.entries('settings.section')[0]!
     expect(entry.component).toBe(ModelsSection)
-    expect(entry.options).toEqual({ id: 'models', order: 10, label: '模型' })
+    expect(entry.options).toMatchObject({ id: 'models', order: 10, label: '模型' })
+    const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
+    expect(injected.t('nav')).toBe('模型')
+    expect(typeof injected.controller.load).toBe('function')
+    expect(typeof injected.useSnapshot).toBe('function')
+    expect(injected.api).toBeDefined()
 
     const after = await bench()
     await after.ctx.plugin({ inject: [...inject], apply }).await()
@@ -93,3 +101,32 @@ describe('ui-models apply', () => {
     expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow()
   })
 })
+
+describe('pushed invalidations', () => {
+  it('ignores invalidations before the page ever loaded', async () => {
+    const b = await bench()
+    declare(b.slots)
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    // The fake wire face has no methods: a fetch attempt would throw.
+    b.ctx.emit('settings/changed', 'llm-pi-ai')
+    b.ctx.emit('credentials/changed', 'OPENAI_API_KEY')
+    b.ctx.emit('models/changed')
+    b.ctx.emit('connection/reset')
+  })
+
+  it('refreshes a loaded page and skips an idle one', () => {
+    const loads: number[] = []
+    const controller = {
+      store: { getSnapshot: () => ({ status: 'ready' }) },
+      load: () => { loads.push(1); return Promise.resolve() },
+    }
+    refreshIfLoaded(controller as unknown as import('../src/client/store.ts').ModelsSettingsStore)
+    expect(loads).toHaveLength(1)
+    const idle = {
+      store: { getSnapshot: () => ({ status: 'idle' }) },
+      load: () => { loads.push(2); return Promise.resolve() },
+    }
+    refreshIfLoaded(idle as unknown as import('../src/client/store.ts').ModelsSettingsStore)
+    expect(loads).toHaveLength(1)
+  })
+})

+ 398 - 0
packages/client/ui-models/tests/components.spec.tsx

@@ -0,0 +1,398 @@
+// @vitest-environment jsdom
+/** Section, editor, and credential-control behavior over a scripted wire face. */
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import Schema from 'schemastery'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
+import { ModelsSection, removeProviderProfile } from '../src/client/ModelsSection.tsx'
+import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
+import { ModelsSettingsStore } from '../src/client/store.ts'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const t: ModelsSectionInjected['t'] = key => en[key]
+
+const PiAiConfig = Schema.object({
+  token: Schema.string().role('secret'),
+  providers: Schema.dict(Schema.object({
+    apiKey: Schema.string().role('secret'),
+    apiKeyEnv: Schema.string().role('credential-ref'),
+    baseURL: Schema.string(),
+    headers: Schema.dict(Schema.string()),
+  })),
+})
+
+const DeepSeekConfig = Schema.object({
+  apiKey: Schema.string().role('secret'),
+  apiKeyEnv: Schema.string().role('credential-ref'),
+  baseURL: Schema.string(),
+  label: Schema.string().required(),
+})
+
+function wireNamespaces(): SettingsNamespaceView[] {
+  return [
+    {
+      ns: 'llm-deepseek',
+      schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
+      value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
+      base: { baseURL: 'https://base' },
+      applies: 'live',
+      secrets: [{ path: ['apiKey'], set: false }],
+    },
+    {
+      ns: 'llm-pi-ai',
+      schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
+      value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
+      user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
+      applies: 'live',
+      secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
+    },
+  ]
+}
+
+let nextRpc = 0
+function ok<T>(value: T): RpcResponse<T> {
+  return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
+}
+function fail<T>(message: string, code = 'settings-rejected'): RpcResponse<T> {
+  return {
+    rpcId: `r-${nextRpc++}` as never,
+    result: { ok: false, error: { code, message, details: { ns: 'x' } } as never },
+  }
+}
+
+function scriptedFace(overrides: {
+  update?: ReturnType<typeof vi.fn>
+  replace?: ReturnType<typeof vi.fn>
+  set?: ReturnType<typeof vi.fn>
+} = {}) {
+  const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[1])))
+  const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[1])))
+  const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
+  const face = {
+    llm: {
+      providers: vi.fn(() => Promise.resolve(ok({
+        providers: [
+          { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
+          { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
+          { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
+          { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
+          { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false },
+        ],
+      }))),
+      models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
+    },
+    settings: {
+      describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
+      update,
+      replace,
+    },
+    credentials: {
+      describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
+        credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
+          configured: ref === 'OPENAI_API_KEY',
+          ...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {},
+          writable: true,
+        }])),
+      }))),
+      set,
+      unset: vi.fn(() => Promise.resolve(ok({}))),
+    },
+  }
+  return { face, update, replace, set }
+}
+
+async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
+  const { face, update, replace, set } = scriptedFace(overrides)
+  const controller = new ModelsSettingsStore(face as never)
+  await controller.load()
+  const injected: ModelsSectionInjected = {
+    controller,
+    useSnapshot: bindSnapshotSelector(controller.store),
+    api: face as never,
+    t,
+  }
+  const view = render(<ModelsSection injected={injected} />)
+  return { view, face, update, replace, set, controller }
+}
+
+describe('ModelsSection', () => {
+  it('renders configured rows with status badges and the add vocabulary', async () => {
+    await mountSection()
+    expect(screen.getByText('DeepSeek')).toBeTruthy()
+    expect(screen.getByText('openai')).toBeTruthy()
+    expect(screen.queryByText('anthropic', { selector: 'span' })).toBeNull()
+    expect(screen.getAllByText(en.active)).toHaveLength(2)
+    // A configured profile whose route did not register renders dormant.
+    expect(screen.getByText(en.dormant)).toBeTruthy()
+    expect(screen.getByText(en.keyMissing)).toBeTruthy()
+    const add = screen.getByLabelText<HTMLSelectElement>(en.add)
+    expect([...add.options].map(option => option.value)).toEqual(['', 'anthropic', 'broken'])
+    expect(screen.getAllByText(en.remove)).toHaveLength(2)
+  })
+
+  it('opens the editor, applies an edit as a merge patch, and reloads', async () => {
+    const { update, face } = await mountSection()
+    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
+    const baseURL = await screen.findByDisplayValue('https://proxy')
+    fireEvent.change(baseURL, { target: { value: 'https://next' } })
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
+    expect(update.mock.calls[0]?.[0]).toEqual({
+      ns: 'llm-pi-ai',
+      patch: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://next', headers: { 'X-Team': 'a' } } } },
+    })
+    await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
+  })
+
+  it('applies a field reset through replace so the removal lands', async () => {
+    const { replace, update } = await mountSection()
+    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
+    const baseURL = await screen.findByDisplayValue('https://proxy')
+    fireEvent.change(baseURL, { target: { value: '' } })
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
+    expect(update).not.toHaveBeenCalled()
+    expect(replace.mock.calls[0]?.[0]).toEqual({
+      ns: 'llm-pi-ai',
+      section: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', headers: { 'X-Team': 'a' } }, zombie: {} } },
+    })
+  })
+
+  it('lands a nested removal (dict entry) through replace', async () => {
+    const { replace } = await mountSection()
+    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
+    await screen.findByDisplayValue('https://proxy')
+    // Row deletion says "Delete"; the only "Remove" inside the open editor
+    // is schema-form's headers-dict row control.
+    fireEvent.click(screen.getAllByText(en.removeLabel)[0] as HTMLElement)
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
+    const section = (replace.mock.calls[0]?.[0] as { section: { providers: { openai: { headers?: unknown } } } }).section
+    expect(section.providers.openai.headers).toEqual({})
+  })
+
+  it('surfaces a rejected apply inside the editor', async () => {
+    const { update } = await mountSection({
+      update: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
+    })
+    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
+    const baseURL = await screen.findByDisplayValue('https://proxy')
+    fireEvent.change(baseURL, { target: { value: 'https://next' } })
+    fireEvent.click(screen.getByText(en.apply))
+    await screen.findByText('llm-pi-ai: unknown pi-ai provider "bogus"')
+    expect(update).toHaveBeenCalledTimes(1)
+  })
+
+  it('adds a dormant provider through the add select and merges its profile in', async () => {
+    const { update } = await mountSection()
+    fireEvent.change(screen.getByLabelText(en.add), { target: { value: 'anthropic' } })
+    const ref = await screen.findByLabelText<HTMLInputElement>(en.credentialRef)
+    // No reference yet, so the write-only key input stays hidden until one exists.
+    expect(screen.queryByLabelText(en.keyInput)).toBeNull()
+    fireEvent.change(ref, { target: { value: 'ANTHROPIC_API_KEY' } })
+    const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
+    expect(key.placeholder).toBe(en.keyPlaceholder)
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
+    expect(update.mock.calls[0]?.[0]).toEqual({
+      ns: 'llm-pi-ai',
+      patch: { providers: { anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' } } },
+    })
+  })
+
+  it('removes a user-added provider through replace', async () => {
+    const { replace } = await mountSection()
+    fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
+    await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
+    expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', section: { providers: { zombie: {} } } })
+  })
+
+  it('reports an unresolvable settings path instead of a blank editor', async () => {
+    await mountSection()
+    fireEvent.change(screen.getByLabelText(en.add), { target: { value: 'broken' } })
+    await screen.findByText(/unresolvable settings path/)
+  })
+
+  it('clears the credential reference back to inherited from the control', async () => {
+    const { update } = await mountSection()
+    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
+    const ref = await screen.findByLabelText<HTMLInputElement>(en.credentialRef)
+    expect(ref.value).toBe('OPENAI_API_KEY')
+    fireEvent.change(ref, { target: { value: '' } })
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(update).toHaveBeenCalledTimes(0) })
+    // Dropping the reference is a removal, so it lands through replace.
+  })
+
+  it('shows the env-shadowed credential badge and hides the key input', async () => {
+    const { face } = await mountSection()
+    face.credentials.describe.mockImplementation(() => Promise.resolve(ok({
+      credentials: { OPENAI_API_KEY: { configured: true, source: 'env', writable: false } },
+    })))
+    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
+    await screen.findByText(content => content.includes(en.credentialFromEnv))
+    expect(screen.queryByLabelText(en.keyInput)).toBeNull()
+  })
+
+  it('renders no badge while the credential domain fails, and keeps a failed post-save describe quiet', async () => {
+    const { face, set } = await mountSection()
+    face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
+    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
+    const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
+    expect(screen.queryByText(en.credentialConfigured)).toBeNull()
+    expect(screen.queryByText(en.credentialMissing)).toBeNull()
+    fireEvent.change(key, { target: { value: 'sk-live' } })
+    fireEvent.click(screen.getByText(en.keySave))
+    await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
+    expect(key).toBeTruthy()
+  })
+
+  it('stores a credential value write-only and refreshes its badge', async () => {
+    const { set, face } = await mountSection()
+    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
+    const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
+    fireEvent.change(key, { target: { value: 'sk-live' } })
+    fireEvent.click(screen.getByText(en.keySave))
+    await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
+    await waitFor(() => { expect(face.credentials.describe.mock.calls.length).toBeGreaterThan(1) })
+    expect(key.value).toBe('')
+  })
+
+  it('surfaces a shadowed credential write on the control', async () => {
+    await mountSection({
+      set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
+    })
+    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
+    const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
+    fireEvent.change(key, { target: { value: 'sk-live' } })
+    fireEvent.click(screen.getByText(en.keySave))
+    await screen.findByText(/shadowed by the read-only environment/)
+  })
+
+  it('renders the load failure with a retry control', async () => {
+    const face = scriptedFace()
+    face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
+    const controller = new ModelsSettingsStore(face.face as never)
+    await controller.load()
+    render(<ModelsSection injected={{
+      controller,
+      useSnapshot: bindSnapshotSelector(controller.store),
+      api: face.face as never,
+      t,
+    }} />)
+    expect(screen.getByText(/directory down/)).toBeTruthy()
+    fireEvent.click(screen.getByText(en.retry))
+    await waitFor(() => { expect(screen.queryByText(/directory down/)).toBeNull() })
+  })
+
+  it('shows the read-only notice and disables mutations for a read-only provider', async () => {
+    const { face } = await mountSection()
+    face.settings.describe.mockImplementation(() => Promise.resolve(ok({
+      writable: false,
+      namespaces: wireNamespaces(),
+    })))
+    const controller = new ModelsSettingsStore(face as never)
+    await controller.load()
+    cleanup()
+    render(<ModelsSection injected={{
+      controller,
+      useSnapshot: bindSnapshotSelector(controller.store),
+      api: face as never,
+      t,
+    }} />)
+    expect(screen.getByText(en.readOnly)).toBeTruthy()
+    expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
+  })
+
+  it('toggles the editor closed on a second edit click and on cancel', async () => {
+    const { update } = await mountSection()
+    const edit = screen.getAllByText(en.edit)[1] as HTMLElement
+    fireEvent.click(edit)
+    await screen.findByDisplayValue('https://proxy')
+    fireEvent.click(edit)
+    expect(screen.queryByDisplayValue('https://proxy')).toBeNull()
+    fireEvent.click(edit)
+    await screen.findByDisplayValue('https://proxy')
+    fireEvent.click(screen.getByText(en.cancel))
+    expect(screen.queryByDisplayValue('https://proxy')).toBeNull()
+    expect(update).not.toHaveBeenCalled()
+  })
+
+  it('ignores the placeholder option of the add select', async () => {
+    await mountSection()
+    fireEvent.change(screen.getByLabelText(en.add), { target: { value: '' } })
+    expect(screen.queryByText(en.apply)).toBeNull()
+  })
+
+  it('applies a whole-section namespace (path []) as a direct patch', async () => {
+    const { update } = await mountSection({
+      update: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
+    })
+    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
+    await screen.findByLabelText(en.credentialRef)
+    const label = screen.getByPlaceholderText<HTMLInputElement>(/label|Default/i) ?? undefined
+    const labelInput = screen.getAllByRole('textbox').find(input =>
+      (input as HTMLInputElement).type === 'text'
+      && input.closest('div')?.previousElementSibling?.textContent?.includes('label') === true)
+    const target = labelInput ?? screen.getAllByRole('textbox').at(-1)
+    fireEvent.change(target as Element, { target: { value: 'Mine' } })
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
+    const payload = update.mock.calls[0]?.[0] as { ns: string; patch: Record<string, unknown> }
+    expect(payload.ns).toBe('llm-deepseek')
+    expect(payload.patch['label']).toBe('Mine')
+    expect(label ?? true).toBeTruthy()
+  })
+
+  it('rejects a section-level invalid draft before writing', async () => {
+    const { update } = await mountSection()
+    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
+    await screen.findByLabelText(en.credentialRef)
+    fireEvent.click(screen.getByText(en.apply))
+    // schemastery names the missing required field in its failure text.
+    await screen.findByText(/required/)
+    expect(update).not.toHaveBeenCalled()
+  })
+
+  it('loads on first render of an idle controller', async () => {
+    const { face } = scriptedFace()
+    const controller = new ModelsSettingsStore(face as never)
+    render(<ModelsSection injected={{
+      controller,
+      useSnapshot: bindSnapshotSelector(controller.store),
+      api: face as never,
+      t,
+    }} />)
+    await screen.findByText('DeepSeek')
+  })
+
+  it('removes against a namespace with no user layer as an empty-section replace', async () => {
+    const { face, replace, controller } = await mountSection()
+    const namespace = controller.store.getSnapshot().namespaces.get('llm-deepseek')
+    await removeProviderProfile(
+      face as unknown as Parameters<typeof removeProviderProfile>[0],
+      controller,
+      { settingsNs: 'llm-deepseek', settingsPath: ['ghost-profile'] },
+      namespace as NonNullable<typeof namespace>,
+    )
+    expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} })
+  })
+
+  it('keeps the snapshot untouched when a removal write is refused', async () => {
+    const { face, controller } = await mountSection({
+      replace: vi.fn(() => Promise.resolve(fail('read-only'))),
+    })
+    const namespace = controller.store.getSnapshot().namespaces.get('llm-pi-ai')
+    const before = controller.store.getSnapshot().rows
+    await removeProviderProfile(
+      face as unknown as Parameters<typeof removeProviderProfile>[0],
+      controller,
+      { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
+      namespace as NonNullable<typeof namespace>,
+    )
+    expect(controller.store.getSnapshot().rows).toBe(before)
+  })
+})

+ 2 - 2
packages/client/ui-models/tests/invariant.spec.ts

@@ -17,7 +17,7 @@ describe('invariant companion', () => {
     expect(true).toBe(true) // reaching here without throw is the contract
   })
 
-  it('the section content column is intentionally empty this phase', () => {
-    expect(ModelsSection()).toBeNull()
+  it('renders null until the shell injects the section dependencies', () => {
+    expect(ModelsSection({})).toBeNull()
   })
 })

+ 226 - 0
packages/client/ui-models/tests/store.spec.ts

@@ -0,0 +1,226 @@
+/** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */
+import { describe, expect, it } from 'vitest'
+import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
+import { ModelsSettingsStore } from '../src/client/store.ts'
+
+let nextRpc = 0
+function ok<T>(value: T): RpcResponse<T> {
+  return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
+}
+function fail<T>(message: string): RpcResponse<T> {
+  return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } }
+}
+
+const DIRECTORY = [
+  { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
+  { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
+  { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
+  { provider: 'ghost', displayName: 'Ghost', settingsNs: '', settingsPath: [], active: true },
+]
+
+const NAMESPACES = [
+  {
+    ns: 'llm-deepseek',
+    schema: {},
+    value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
+    base: { baseURL: 'https://base' },
+    applies: 'live' as const,
+    secrets: [{ path: ['apiKey'], set: false }],
+  },
+  {
+    ns: 'llm-pi-ai',
+    schema: {},
+    value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
+    user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
+    applies: 'live' as const,
+    secrets: [],
+  },
+]
+
+function api(overrides: {
+  providers?: () => Promise<RpcResponse<{ providers: typeof DIRECTORY }>>
+  describeSettings?: () => Promise<RpcResponse<{ writable: boolean; namespaces: typeof NAMESPACES }>>
+  describeCredentials?: (refs: string[]) => Promise<RpcResponse<{ credentials: Record<string, unknown> }>>
+} = {}) {
+  const seenRefs: string[][] = []
+  const face = {
+    llm: {
+      providers: overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))),
+      models: () => Promise.resolve(ok({ groups: [], failures: [] })),
+    },
+    settings: {
+      describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))),
+      update: () => Promise.resolve(fail('unused')),
+      replace: () => Promise.resolve(fail('unused')),
+    },
+    credentials: {
+      describe: (payload: { refs: string[] }) => {
+        seenRefs.push(payload.refs)
+        return (overrides.describeCredentials ?? (refs => Promise.resolve(ok({
+          credentials: Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])),
+        }))))(payload.refs)
+      },
+      set: () => Promise.resolve(ok({})),
+      unset: () => Promise.resolve(ok({})),
+    },
+  }
+  return { face: face as never, seenRefs }
+}
+
+describe('ModelsSettingsStore', () => {
+  it('joins rows with configured, removable, and credential state', async () => {
+    const { face, seenRefs } = api()
+    const store = new ModelsSettingsStore(face)
+    await store.load()
+    const state = store.store.getSnapshot()
+    expect(state.status).toBe('ready')
+    expect(state.writable).toBe(true)
+    expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
+    const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
+    expect(byProvider.get('deepseek-official')).toMatchObject({
+      configured: true,
+      removable: false,
+      apiKeyEnv: 'DEEPSEEK_API_KEY',
+      credential: { configured: false, writable: true },
+    })
+    expect(byProvider.get('openai')).toMatchObject({
+      configured: true,
+      removable: true,
+      apiKeyEnv: 'OPENAI_API_KEY',
+      credential: { configured: true },
+    })
+    expect(byProvider.get('anthropic')).toMatchObject({ configured: false, removable: false })
+    expect(byProvider.get('anthropic')?.apiKeyEnv).toBeUndefined()
+    expect(byProvider.get('ghost')).toMatchObject({ configured: false, removable: false })
+    expect(state.namespaces.get('llm-pi-ai')?.ns).toBe('llm-pi-ai')
+  })
+
+  it('degrades the credential badge, not the page, when the credential domain fails', async () => {
+    const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
+    const store = new ModelsSettingsStore(face)
+    await store.load()
+    const state = store.store.getSnapshot()
+    expect(state.status).toBe('ready')
+    expect(state.rows.every(row => row.credential === undefined)).toBe(true)
+  })
+
+  it('surfaces a directory failure and keeps the last good rows', async () => {
+    const { face } = api()
+    const store = new ModelsSettingsStore(face)
+    await store.load()
+    expect(store.store.getSnapshot().rows).toHaveLength(4)
+    const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
+    const failing = new ModelsSettingsStore(broken.face)
+    await failing.load()
+    expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
+    // The first store's snapshot is untouched by the second's failure.
+    expect(store.store.getSnapshot().status).toBe('ready')
+  })
+
+  it('lets the newest load win over a stale slow response', async () => {
+    let release: (() => void) | undefined
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    let call = 0
+    const { face } = api({
+      providers: async () => {
+        call += 1
+        if (call === 1) {
+          await gate
+          return fail('stale slow failure')
+        }
+        return ok({ providers: DIRECTORY })
+      },
+    })
+    const store = new ModelsSettingsStore(face)
+    const first = store.load()
+    const second = store.load()
+    release?.()
+    await Promise.all([first, second])
+    expect(store.store.getSnapshot().status).toBe('ready')
+  })
+})
+
+describe('edge joins', () => {
+  it('treats a non-object profile as having no credential reference', async () => {
+    const { face } = api({
+      describeSettings: () => Promise.resolve(ok({
+        writable: true,
+        namespaces: [{
+          ns: 'llm-pi-ai',
+          schema: {},
+          value: { providers: { weird: 'oops' } },
+          applies: 'live' as const,
+          secrets: [],
+        }] as never,
+      })),
+      providers: () => Promise.resolve(ok({
+        providers: [
+          { provider: 'weird', displayName: 'weird', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'weird'], active: false },
+        ] as never,
+      })),
+    })
+    const store = new ModelsSettingsStore(face)
+    await store.load()
+    const state = store.store.getSnapshot()
+    expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
+    expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
+  })
+
+  it('skips the credential describe entirely when no row names a reference', async () => {
+    const { face, seenRefs } = api({
+      describeSettings: () => Promise.resolve(ok({
+        writable: true,
+        namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [] }] as never,
+      })),
+      providers: () => Promise.resolve(ok({
+        providers: [
+          { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
+        ] as never,
+      })),
+    })
+    const store = new ModelsSettingsStore(face)
+    await store.load()
+    expect(seenRefs).toEqual([])
+    expect(store.store.getSnapshot().status).toBe('ready')
+  })
+
+  it('surfaces a settings describe failure', async () => {
+    const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
+    const store = new ModelsSettingsStore(face)
+    await store.load()
+    expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
+  })
+
+  it('stringifies a non-Error load failure', async () => {
+    // The wire can surface non-Error throwables; the store must stringify them.
+    // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
+    const { face } = api({ providers: () => Promise.reject('plain refusal') })
+    const store = new ModelsSettingsStore(face)
+    await store.load()
+    expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
+  })
+
+  it('drops a stale successful response after a newer load finished', async () => {
+    let release: (() => void) | undefined
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    let call = 0
+    const { face } = api({
+      providers: async () => {
+        call += 1
+        if (call === 1) {
+          await gate
+          return ok({ providers: [] as never })
+        }
+        return ok({ providers: DIRECTORY })
+      },
+    })
+    const store = new ModelsSettingsStore(face)
+    const first = store.load()
+    const second = store.load()
+    await second
+    release?.()
+    await first
+    // The stale empty directory never overwrote the newer join.
+    expect(store.store.getSnapshot().rows).toHaveLength(4)
+  })
+})

+ 9 - 0
packages/client/ui-models/tsconfig.json

@@ -17,6 +17,15 @@
     {
       "path": "../runtime"
     },
+    {
+      "path": "../connection"
+    },
+    {
+      "path": "../schema-form"
+    },
+    {
+      "path": "../web-react"
+    },
     {
       "path": "../ui-settings"
     },

+ 9 - 0
pnpm-lock.yaml

@@ -1194,18 +1194,27 @@ importers:
 
   packages/client/ui-models:
     devDependencies:
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
+      '@deepseek-ai/dsh-client-schema-form':
+        specifier: workspace:^
+        version: link:../schema-form
       '@deepseek-ai/dsh-client-ui-settings':
         specifier: workspace:^
         version: link:../ui-settings
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-web-react':
+        specifier: workspace:^
+        version: link:../web-react
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants

+ 2 - 0
tsconfig.base.json

@@ -118,6 +118,8 @@
       "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
       "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],
       "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],
+      "@deepseek-ai/dsh-client-schema-form": ["./packages/client/schema-form/src"],
+      "@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"],
       "@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"],
       "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"],
       "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"],