feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id

**Reasoning effort leaves the provider cards entirely.** It is a per-MODEL
capability and the models under one provider disagree about which levels
they accept: setting `anthropic` to `max` made six of its eight models
throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches
per provider, the whole provider vanished from the picker behind one error
row. A provider-scoped control can only ever be set to a value some of its
models reject.

The composer's model picker already offers each model its own levels, and
a switch there now records provider, model, and effort together as the
next session's default — so the setting has a better home at the right
granularity. The profile field stays in `settings.yaml` for a deployment
that knows its route; only the control is gone, from both cards and both
adapter families. Two `components.spec` cases used the control as the
vehicle for their op assertions and now use `baseURL`, which is what they
were actually testing.

**A rejected Provider ID now reads as a fault.** It shared the neutral
hint paragraph with the field's guidance, so the copy telling the user
what they got wrong looked like advice. Reuses the existing `.error`
style, matching the split the key field already makes.
This commit is contained in:
Yichen Jiang
2026-08-07 17:36:08 +08:00
parent 135064c831
commit 2dc1406dfd
12 changed files with 70 additions and 178 deletions

View File

@@ -14,13 +14,11 @@
* and at least one model — are required here rather than at load, so the
* failure names the field while the user is still looking at it.
*
* There is deliberately no reasoning-effort control. A hand-declared model
* carries no reasoning capability — pi-ai's installed catalog is what supplies
* one, and it has nothing under this route — so a profile effort here makes
* `resolveModel` throw UNSUPPORTED_REASONING_EFFORT for every model on the
* route, which drops the whole provider out of the model picker. The editor
* card hides the control for the same reason once the directory reports the
* route as declared.
* There is deliberately no reasoning-effort control, here or on the editor
* card: effort is a per-MODEL capability, and the models under one provider
* disagree about it, so a provider-scoped control can only be set to a value
* some of them reject. The composer's model picker offers each model its own
* levels instead.
*/
import { useState } from 'react'
@@ -206,9 +204,11 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
onChange={(event) => { setRoute(event.target.value) }}
/>
</div>
<p className={styles['advancedHint']}>
{routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')}
</p>
{/* A rejected id reads as a fault, not as guidance — the same split the
key field below already makes between its failure and its hint. */}
{routeInvalid || routeTaken
? <p className={styles['error']}>{t(routeInvalid ? 'customRouteInvalid' : 'customRouteTaken')}</p>
: <p className={styles['advancedHint']}>{t('customRouteHint')}</p>}
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
<input

View File

@@ -54,8 +54,6 @@ interface EditorTarget extends ProviderIdentity {
settingsPath: readonly string[]
/** Writable credential identified under this page's conventional reference. */
credentialRef?: string
/** Directory passthrough: the owning adapter ships nothing under this route. */
declared?: boolean
}
/** Values that vary around the shared provider-editor rendering. */
@@ -73,7 +71,6 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
provider={target.provider}
displayName={target.displayName}
settingsPath={target.settingsPath}
{...target.declared === undefined ? {} : { declared: target.declared }}
{...props}
/>
)
@@ -140,7 +137,6 @@ function targetOf(row: ProviderRow): EditorTarget {
settingsNs: row.entry.settingsNs,
settingsPath: row.entry.settingsPath,
...credentialRef === undefined ? {} : { credentialRef },
...row.entry.declared === undefined ? {} : { declared: row.entry.declared },
}
}

View File

@@ -7,10 +7,12 @@
* a key is entered; a blank key materializes a reference-free profile for
* provider-native authentication);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai —
* withheld for a hand-declared route, whose models have no reasoning
* capability to configure — and DeepSeek's id/name/context-window model
* catalog). Everything else stays
* both families and DeepSeek's id/name/context-window model catalog).
* Reasoning effort is deliberately absent: it is a per-MODEL capability, and
* the models under one provider disagree about it, so a provider-scoped
* control can only be set to a value some of them reject. The composer's
* model picker offers each model its own levels; `settings.yaml` keeps the
* profile field for a deployment that knows its route. Everything else stays
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
* path ops against the stored section — the card reads the redacted
* descriptor, so it names only the fields it can see and a stored literal
@@ -29,14 +31,12 @@ import {
import { apiKeyFailure } from './apiKey.ts'
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 = EffortFamily | 'unknown'
type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
@@ -57,13 +57,6 @@ export interface ProviderEditorProps {
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/**
* Whether the owning adapter knows this route only because configuration
* declared it. Such a route's models carry no reasoning capability, so the
* effort control is withheld; absent means the adapter draws no such
* distinction and the control shows.
*/
declared?: boolean
/** Disable writes (read-only settings provider). */
readOnly: boolean
/** Close the editor; `changed` reports whether an Apply committed. */
@@ -303,8 +296,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: EffortFamily): ReactNode => {
const effortField = EFFORT_FIELD[family]
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
const customModels = getPath(draft, ['models'])
const modelsOverridden = hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
@@ -361,21 +353,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
}}
/>
</div>
{/* A hand-declared route's models carry no reasoning capability
(pi-ai's installed catalog is what supplies one, and it has
nothing under such a route), so a profile effort would make
`resolveModel` throw for every model on it and drop the whole
provider out of the picker. Offering the control at all would
be offering a way to break the route. */}
{props.declared === true ? null : (
<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. */}

View File

@@ -1,72 +0,0 @@
/**
* The provider-level reasoning-effort select: 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.
*
* It carries the per-family vocabulary and field name so the editor's two
* layouts cannot spell them differently. Only routes the adapter ships get
* this control at all — a hand-declared model has no reasoning capability to
* configure, and a profile effort over one makes its whole route fail to
* resolve — so the create card renders nothing here by construction.
*/
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>
)
}

View File

@@ -35,8 +35,6 @@ export const en = {
customized: 'Customized settings',
baseUrl: 'Base URL',
baseUrlDefault: 'Provider default',
effort: 'Reasoning effort',
effortInherit: 'Default',
models: 'Models',
modelsInherited: 'Using the adapter defaults',
modelsCustomized: 'Customized model catalog',
@@ -130,8 +128,6 @@ export const zh: typeof en = {
customized: '自定义设置',
baseUrl: 'API 地址',
baseUrlDefault: '提供方默认',
effort: '推理强度',
effortInherit: '默认',
models: '模型目录',
modelsInherited: '正在使用适配器默认模型',
modelsCustomized: '已自定义模型目录',