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.
This commit is contained in:
@@ -22,6 +22,7 @@ import { EditorFooter } from './EditorFooter.tsx'
|
|||||||
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
|
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
|
||||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||||
import type { ModelDraft } from './ModelListEditor.tsx'
|
import type { ModelDraft } from './ModelListEditor.tsx'
|
||||||
|
import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx'
|
||||||
import { deriveKeyRef, messageOf } from './store.ts'
|
import { deriveKeyRef, messageOf } from './store.ts'
|
||||||
import type { en } from './locales.ts'
|
import type { en } from './locales.ts'
|
||||||
import styles from './ModelsSection.module.css'
|
import styles from './ModelsSection.module.css'
|
||||||
@@ -69,6 +70,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
|||||||
const [baseURL, setBaseURL] = useState('')
|
const [baseURL, setBaseURL] = useState('')
|
||||||
const [protocol, setProtocol] = useState(protocols[0] ?? '')
|
const [protocol, setProtocol] = useState(protocols[0] ?? '')
|
||||||
const [keyDraft, setKeyDraft] = useState('')
|
const [keyDraft, setKeyDraft] = useState('')
|
||||||
|
const [effort, setEffort] = useState<string | undefined>(undefined)
|
||||||
const [models, setModels] = useState<readonly ModelDraft[]>([])
|
const [models, setModels] = useState<readonly ModelDraft[]>([])
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||||
@@ -101,6 +103,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
|||||||
apiKeyEnv: keyRef,
|
apiKeyEnv: keyRef,
|
||||||
api: protocol,
|
api: protocol,
|
||||||
baseURL,
|
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 })),
|
models: models.map(model => ({ ...model })),
|
||||||
}
|
}
|
||||||
const response = await api.settings.mutate({
|
const response = await api.settings.mutate({
|
||||||
@@ -209,6 +214,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
|||||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<ModelListEditor
|
||||||
models={models}
|
models={models}
|
||||||
onChange={setModels}
|
onChange={setModels}
|
||||||
|
|||||||
@@ -24,24 +24,14 @@ import {
|
|||||||
} from './DeepSeekModelsEditor.tsx'
|
} from './DeepSeekModelsEditor.tsx'
|
||||||
import { EditorFooter } from './EditorFooter.tsx'
|
import { EditorFooter } from './EditorFooter.tsx'
|
||||||
import { ModelListEditor } from './ModelListEditor.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 { deriveKeyRef, messageOf } from './store.ts'
|
||||||
import type { en } from './locales.ts'
|
import type { en } from './locales.ts'
|
||||||
import styles from './ModelsSection.module.css'
|
import styles from './ModelsSection.module.css'
|
||||||
|
|
||||||
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
|
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
|
||||||
type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
|
type EditorLayout = EffortFamily | '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',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
|
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
|
||||||
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
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
|
* family as a parameter is what makes `EFFORT_FIELD` total here: an
|
||||||
* unknown namespace never reaches this body.
|
* unknown namespace never reaches this body.
|
||||||
*/
|
*/
|
||||||
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
|
const curatedFields = (family: EffortFamily): ReactNode => {
|
||||||
const effortField = EFFORT_FIELD[family]
|
const effortField = EFFORT_FIELD[family]
|
||||||
const customModels = getPath(draft, ['models'])
|
const customModels = getPath(draft, ['models'])
|
||||||
const modelsOverridden = hasPath(draft, ['models'])
|
const modelsOverridden = hasPath(draft, ['models'])
|
||||||
@@ -333,23 +323,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles['field']}>
|
<ReasoningEffortField
|
||||||
<span className={styles['fieldLabel']}>{t('effort')}</span>
|
family={family}
|
||||||
<select
|
value={stringAt(draft, effortField) ?? ''}
|
||||||
className={`${styles['input']} ${styles['selectInput']}`}
|
onChange={(effort) => { setField(effortField, effort) }}
|
||||||
value={stringAt(draft, effortField) ?? ''}
|
t={t}
|
||||||
aria-label={t('effort')}
|
disabled={disabled}
|
||||||
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>
|
|
||||||
{/* Both families edit the same rows through the same contract; only
|
{/* Both families edit the same rows through the same contract; only
|
||||||
the extras differ — DeepSeek's inherited capacities, pi-ai's
|
the extras differ — DeepSeek's inherited capacities, pi-ai's
|
||||||
endpoint interrogation. */}
|
endpoint interrogation. */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -652,6 +652,40 @@ describe('hand-declared providers', () => {
|
|||||||
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
|
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', () => {
|
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
|
||||||
mountCard()
|
mountCard()
|
||||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||||
|
|||||||
Reference in New Issue
Block a user