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:
Yichen Jiang
2026-08-07 13:25:46 +08:00
parent f2d1a29636
commit e0f9f7a6e6
4 files changed
+130 -31

No files matched your search

@@ -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}
@@ -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. */}
@@ -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>
)
}