Просмотр исходного кода

fix(ui-models): let a hand-declared route set its reasoning effort

The create card omitted the provider-level effort the editor card offers
for the same namespace, so a route declared through 添加自定义提供方 gained
a setting the moment it was reopened for editing — one the creating user
was never shown.

Both cards now render one shared control. The field, its vocabulary, and
the inherit-means-absent rule live with the control rather than in the
editor, which is what stops the two from drifting apart again.
Yichen Jiang 2 месяцев назад
Родитель
Сommit
e0f9f7a6e6

+ 14 - 0
packages/client/ui-models/src/client/CustomProviderCard.tsx

@@ -22,6 +22,7 @@ import { EditorFooter } from './EditorFooter.tsx'
 import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
 import { ModelListEditor } from './ModelListEditor.tsx'
 import type { ModelDraft } from './ModelListEditor.tsx'
+import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx'
 import { deriveKeyRef, messageOf } from './store.ts'
 import type { en } from './locales.ts'
 import styles from './ModelsSection.module.css'
@@ -69,6 +70,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
   const [baseURL, setBaseURL] = useState('')
   const [protocol, setProtocol] = useState(protocols[0] ?? '')
   const [keyDraft, setKeyDraft] = useState('')
+  const [effort, setEffort] = useState<string | undefined>(undefined)
   const [models, setModels] = useState<readonly ModelDraft[]>([])
   const [busy, setBusy] = useState(false)
   const [failure, setFailure] = useState<string | undefined>(undefined)
@@ -101,6 +103,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
       apiKeyEnv: keyRef,
       api: protocol,
       baseURL,
+      // Inherit is the field being absent, not an empty string: the schema
+      // types it as an effort name, and an empty one would fail the write.
+      ...effort === undefined ? {} : { [EFFORT_FIELD['pi-ai']]: effort },
       models: models.map(model => ({ ...model })),
     }
     const response = await api.settings.mutate({
@@ -209,6 +214,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           onChange={(event) => { setKeyDraft(event.target.value) }}
         />
       </div>
+      {/* The same control the editor card shows for this namespace: a route
+          declared here and edited there must offer the same profile. */}
+      <ReasoningEffortField
+        family="pi-ai"
+        value={effort ?? ''}
+        onChange={setEffort}
+        t={t}
+        disabled={disabled}
+      />
       <ModelListEditor
         models={models}
         onChange={setModels}

+ 11 - 31
packages/client/ui-models/src/client/ProviderEditor.tsx

@@ -24,24 +24,14 @@ import {
 } from './DeepSeekModelsEditor.tsx'
 import { EditorFooter } from './EditorFooter.tsx'
 import { ModelListEditor } from './ModelListEditor.tsx'
+import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx'
+import type { EffortFamily } from './ReasoningEffortField.tsx'
 import { deriveKeyRef, messageOf } from './store.ts'
 import type { en } from './locales.ts'
 import styles from './ModelsSection.module.css'
 
 /** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
-type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
-
-/** Reasoning vocabularies per layout; the empty option means "inherit". */
-const EFFORT_CHOICES: Record<'deepseek' | 'pi-ai', readonly string[]> = {
-  deepseek: ['off', 'high', 'max'],
-  'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
-}
-
-/** The draft key the effort select edits, per layout. */
-const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = {
-  deepseek: 'reasoningEffort',
-  'pi-ai': 'reasoning',
-}
+type EditorLayout = EffortFamily | 'unknown'
 
 /** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
 const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
@@ -279,7 +269,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
    * family as a parameter is what makes `EFFORT_FIELD` total here: an
    * unknown namespace never reaches this body.
    */
-  const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
+  const curatedFields = (family: EffortFamily): ReactNode => {
     const effortField = EFFORT_FIELD[family]
     const customModels = getPath(draft, ['models'])
     const modelsOverridden = hasPath(draft, ['models'])
@@ -333,23 +323,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
                 }}
               />
             </div>
-            <div className={styles['field']}>
-              <span className={styles['fieldLabel']}>{t('effort')}</span>
-              <select
-                className={`${styles['input']} ${styles['selectInput']}`}
-                value={stringAt(draft, effortField) ?? ''}
-                aria-label={t('effort')}
-                disabled={disabled}
-                onChange={(event) => {
-                  setField(effortField, event.target.value === '' ? undefined : event.target.value)
-                }}
-              >
-                <option value="">{t('effortInherit')}</option>
-                {EFFORT_CHOICES[family].map(choice => (
-                  <option key={choice} value={choice}>{choice}</option>
-                ))}
-              </select>
-            </div>
+            <ReasoningEffortField
+              family={family}
+              value={stringAt(draft, effortField) ?? ''}
+              onChange={(effort) => { setField(effortField, effort) }}
+              t={t}
+              disabled={disabled}
+            />
             {/* Both families edit the same rows through the same contract; only
                 the extras differ — DeepSeek's inherited capacities, pi-ai's
                 endpoint interrogation. */}

+ 71 - 0
packages/client/ui-models/src/client/ReasoningEffortField.tsx

@@ -0,0 +1,71 @@
+/**
+ * The provider-level reasoning-effort select, shared by every card that writes
+ * a provider profile. It lives here rather than inside one card because both
+ * write the SAME field of the same profile: a route declared without this
+ * control and then edited with it would offer a setting the creating user was
+ * never given, which is exactly the drift that put it here.
+ *
+ * The value is the profile's own default effort, applied to every model on the
+ * route unless a request names one; the empty option means "inherit", which on
+ * the wire is the field being absent rather than an empty string.
+ */
+
+import type { ReactNode } from 'react'
+import type { en } from './locales.ts'
+import styles from './ModelsSection.module.css'
+
+/** The adapter families that expose a provider-level effort, and their vocabularies. */
+export type EffortFamily = 'deepseek' | 'pi-ai'
+
+/** Reasoning vocabularies per family; the empty option means "inherit". */
+export const EFFORT_CHOICES: Record<EffortFamily, readonly string[]> = {
+  deepseek: ['off', 'high', 'max'],
+  'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
+}
+
+/** The profile key each family's effort lives under. */
+export const EFFORT_FIELD: Record<EffortFamily, string> = {
+  deepseek: 'reasoningEffort',
+  'pi-ai': 'reasoning',
+}
+
+/** Props of {@link ReasoningEffortField}. */
+export interface ReasoningEffortFieldProps {
+  /** Which vocabulary to offer. */
+  family: EffortFamily
+  /** Current value; the empty string is the inherit option. */
+  value: string
+  /** Receives the chosen effort, or undefined for inherit. */
+  onChange: (effort: string | undefined) => void
+  /** Section copy. */
+  t: (key: keyof typeof en) => string
+  /** Disable the control (busy or read-only). */
+  disabled: boolean
+}
+
+/**
+ * Render the provider-level reasoning-effort select.
+ * @param props - family vocabulary, current value, change sink, copy, and disabled state.
+ * @returns the labelled select.
+ */
+export function ReasoningEffortField(
+  { family, value, onChange, t, disabled }: ReasoningEffortFieldProps,
+): ReactNode {
+  return (
+    <div className={styles['field']}>
+      <span className={styles['fieldLabel']}>{t('effort')}</span>
+      <select
+        className={`${styles['input']} ${styles['selectInput']}`}
+        value={value}
+        aria-label={t('effort')}
+        disabled={disabled}
+        onChange={(event) => { onChange(event.target.value === '' ? undefined : event.target.value) }}
+      >
+        <option value="">{t('effortInherit')}</option>
+        {EFFORT_CHOICES[family].map(choice => (
+          <option key={choice} value={choice}>{choice}</option>
+        ))}
+      </select>
+    </div>
+  )
+}

+ 34 - 0
packages/client/ui-models/tests/provider-form.spec.tsx

@@ -652,6 +652,40 @@ describe('hand-declared providers', () => {
     expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
   })
 
+  it('offers the same reasoning effort the editor does, and omits it when inherited', async () => {
+    const { mutate, onClose } = mountCard()
+    const declare = (): void => {
+      fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
+      fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
+      fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+      fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+    }
+    declare()
+
+    // The vocabulary is the namespace's, not DeepSeek's — a route declared
+    // here is edited by the pi-ai layout, which offers exactly these.
+    const select = screen.getByLabelText(en.effort) as HTMLSelectElement
+    expect([...select.options].map(option => option.value))
+      .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
+
+    fireEvent.change(select, { target: { value: 'high' } })
+    fireEvent.click(screen.getByText(en.create))
+    await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
+    expect(firstMutate(mutate).ops[0]).toMatchObject({
+      path: ['providers', 'acme'],
+      value: { reasoning: 'high' },
+    })
+
+    // Inherit is the field being absent: an empty string would fail the schema
+    // that types this as an effort name.
+    cleanup()
+    const second = mountCard()
+    declare()
+    fireEvent.click(screen.getByText(en.create))
+    await waitFor(() => { expect(second.onClose).toHaveBeenCalledWith(true) })
+    expect(firstMutate(second.mutate).ops[0].value).not.toHaveProperty('reasoning')
+  })
+
   it('names the blocked gate under the form, and nothing once it is satisfied', () => {
     mountCard()
     fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })