Default model: let the picker choose a reasoning effort

The header switcher only offered provider and model, so catalog efforts a brand-new Session could inherit were reachable solely by typing effort into the Eval and Execution forms. Rows whose catalog entry advertises efforts now expand a second tier — the model default plus each effort, with descriptions and a check on the route in effect — the switch validates the effort against the catalog, treats an effort-only change as a real switch, and forwards the branded id to selectModel. Docs name the re-pointing that dee76b1 added.
This commit is contained in:
2026-11-02 08:32:53 +08:00
parent acacbb2d91
commit 17be661615
7 changed files with 122 additions and 24 deletions
+8 -2
View File
@@ -14,7 +14,7 @@ import { RoleModelSchema } from '../policy.ts'
/** Why a default-model switch was refused, localized by the panel through `defaultModelError.<reason>`. */
export class DefaultModelError extends Error {
constructor(readonly reason: 'unknownProvider' | 'unknownModel' | 'unchanged' | 'unsaved', detail?: string) {
constructor(readonly reason: 'unknownProvider' | 'unknownModel' | 'unknownEffort' | 'unchanged' | 'unsaved', detail?: string) {
super(detail ?? reason)
}
}
@@ -52,8 +52,14 @@ export async function switchDefaultModel(ctx: Context, masterId: string, input:
const wanted = RoleModelSchema.parse(input)
const catalog = await ctx.sessionController.modelCatalog()
checked(catalog, wanted.provider, wanted.model)
// The picker sends provider and model only; the Controller resolves the effort, so effort never makes a route "changed".
const entry = catalog.groups.find(group => group.id === wanted.provider)?.models.find(model => model.id === wanted.model)
if (wanted.reasoningEffort && !entry?.reasoning?.efforts.some(effort => effort.id === wanted.reasoningEffort)) {
throw new DefaultModelError('unknownEffort', `Model "${wanted.provider}/${wanted.model}" does not offer effort "${wanted.reasoningEffort}"`)
}
// The Controller resolves an implicit effort to the model's default, so compare against the effort the switch will settle on.
const predicted = wanted.reasoningEffort ?? entry?.reasoning?.defaultEffort
const unchanged = catalog.default.provider === wanted.provider && catalog.default.model === wanted.model
&& (catalog.default.reasoningEffort ?? '') === (predicted ?? '')
if (unchanged) throw new DefaultModelError('unchanged')
// The Controller resolves the call config, so the selection it accepts may carry an effort the caller left implicit.
const { selected } = await ctx.sessionController.selectModel({ sessionId: masterId as SessionId, provider: wanted.provider,
+1
View File
@@ -389,3 +389,4 @@ a.dt-btn, a.dt-btn:hover { text-decoration: none; }
.dt-model-option:hover:not(:disabled), .dt-model-option:focus-visible { background: var(--dsw-alias-bg-layer-1); }
.dt-model-option[aria-selected='true'] { color: var(--dsw-alias-brand-primary); font-weight: 600; }
.dt-model-picker-error { color: var(--dsw-alias-state-error-primary); font-size: 13px; }
.dt-model-efforts { position: static; max-height: none; min-width: 0; border: 0; box-shadow: none; background: transparent; padding: 0 0 2px 14px; z-index: auto; }
+44 -15
View File
@@ -2,7 +2,9 @@
* Header-level default-model switcher: one dropdown that changes the DSH deployment default for every new Session.
*
* The list comes from `GET models` — the same catalog the DSH picker renders — so an operator switches between exactly the
* routes a new Executor or Assessor Session could inherit, and rejects come back as localized reasons.
* routes a new Executor or Assessor Session could inherit, and rejects come back as localized reasons. Models whose catalog
* entry advertises reasoning efforts expand into a second tier of effort choices; the tier's first entry switches the model
* without an explicit effort, which is exactly what DSH resolves when a Session picks the model bare.
*/
import { useState } from 'react'
import { ApiError, usePoll, type ApiResponse } from './api.ts'
@@ -13,16 +15,19 @@ import type { T } from './text.ts'
export type DefaultModelCatalog = ApiResponse<'GET models'>
/** Render a selection the way the operator reads one elsewhere in the panel. */
const selection = (model: { provider: string; model: string }): string => `${model.provider} · ${model.model}`
const selection = (model: { provider: string; model: string; reasoningEffort?: string }): string =>
`${model.provider} · ${model.model}${model.reasoningEffort ? ` · ${model.reasoningEffort}` : ''}`
export function DefaultModelPicker() {
const { t, call } = usePanel()
const { reload: reloadData } = usePanelData()
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState<string>()
const { data: catalog, error, reload } = usePoll(() => call('GET models'), [], 30_000)
const pick = useAction(async (provider: string, model: string) => {
const pick = useAction(async (provider: string, model: string, effort?: string) => {
setOpen(false)
await call('PUT default-model', { body: { provider, model } })
setExpanded(undefined)
await call('PUT default-model', { body: { provider, model, ...effort ? { reasoningEffort: effort } : {} } })
await reload()
await reloadData()
}, { failure: failure => defaultModelRefusal(t, failure) })
@@ -30,22 +35,46 @@ export function DefaultModelPicker() {
if (!catalog) return <span className="dt-model-picker dt-hint">{t('loading')}</span>
const current = catalog.default
const options = catalog.groups.flatMap(group => group.models.map(model =>
({ provider: group.id, model: model.id, text: `${group.name} · ${model.name}` })))
({ provider: group.id, model, text: `${group.name} · ${model.name}` })))
return <div className="dt-model-picker">
<button type="button" className="dt-btn dt-btn-outline dt-btn-sm" aria-haspopup="listbox" aria-expanded={open}
title={t('defaultModelQuickTip')} disabled={pick.busy} onClick={() => setOpen(value => !value)}>
<Icon name="settings" size={14} />{t('defaultModelQuick')}:{selection(current)}
</button>
{open && <ul className="dt-model-menu" role="listbox" aria-label={t('defaultModelQuick')}>
{options.map(option => <li key={`${option.provider}/${option.model}`}>
<button type="button" role="option" className="dt-model-option" disabled={pick.busy}
aria-selected={option.provider === current.provider && option.model === current.model}
onClick={() => {
// Picking the current default would be refused by the host; just close, so a mis-click never toasts.
if (option.provider === current.provider && option.model === current.model) { setOpen(false); return }
void pick.run(option.provider, option.model)
}}>{option.text}</button>
</li>)}
{options.map(option => {
const key = `${option.provider}/${option.model.id}`
const isCurrent = option.provider === current.provider && option.model.id === current.model
const efforts = option.model.reasoning?.efforts ?? []
return <li key={key}>
<button type="button" role="option" className="dt-model-option" disabled={pick.busy} aria-selected={isCurrent}
aria-expanded={efforts.length ? expanded === key : undefined}
// A model without efforts switches on its own row; one with efforts only expands, so the effort never stays ambiguous.
onClick={() => {
if (efforts.length) { setExpanded(value => value === key ? undefined : key); return }
// Picking the current default would be refused by the host; just close, so a mis-click never toasts.
if (isCurrent) setOpen(false)
else void pick.run(option.provider, option.model.id)
}}>
{option.text}{efforts.length ? ' ›' : ''}
</button>
{expanded === key && efforts.length > 0 && <ul className="dt-model-menu dt-model-efforts" aria-label={t('defaultModelEffortTitle')}>
<li>
<button type="button" className="dt-model-option" disabled={pick.busy} onClick={() => {
// The bare switch settles on the model default, so a mis-click on the route already in effect just closes.
if (isCurrent && !current.reasoningEffort) setOpen(false)
else void pick.run(option.provider, option.model.id)
}}>{t('defaultModelEffortDefault')}{isCurrent && !current.reasoningEffort ? ' ✓' : ''}</button>
</li>
{efforts.map(effort => <li key={effort.id}>
<button type="button" className="dt-model-option" disabled={pick.busy} title={effort.description} onClick={() => {
if (isCurrent && current.reasoningEffort === effort.id) setOpen(false)
else void pick.run(option.provider, option.model.id, effort.id)
}}>{effort.name}{effort.description ? ` · ${effort.description}` : ''}{isCurrent && current.reasoningEffort === effort.id ? ' ✓' : ''}</button>
</li>)}
</ul>}
</li>
})}
</ul>}
</div>
}
@@ -55,5 +84,5 @@ export function defaultModelRefusal(t: T, failure: unknown): string {
const code = failure instanceof ApiError ? failure.details.code : undefined
const reason = code?.startsWith('defaultModel.') ? code.slice('defaultModel.'.length) : undefined
const text = failure instanceof Error ? failure.message : String(failure)
return reason ? t(`defaultModelError.${reason as 'unknownProvider' | 'unknownModel' | 'unchanged' | 'unsaved'}`) : text
return reason ? t(`defaultModelError.${reason as 'unknownProvider' | 'unknownModel' | 'unknownEffort' | 'unchanged' | 'unsaved'}`) : text
}
+3
View File
@@ -424,9 +424,12 @@ const TEXT = {
modelCatalogError: ['无法加载模型建议,仍可填写已知的服务商和模型:{error}', 'Model suggestions are unavailable; known Provider and Model names still work: {error}'],
defaultModelQuick: ['默认模型', 'Default model'],
defaultModelQuickTip: ['切换后,之后新建的执行器与研判 Session 都继承这个默认模型;已有 Session 保持原选择。', 'Switching changes what new Executor and Assessor Sessions inherit; existing Sessions keep their choice.'],
defaultModelEffortTitle: ['思考强度', 'Reasoning effort'],
defaultModelEffortDefault: ['模型默认', 'Model default'],
'defaultModelError.load': ['无法读取默认模型列表', 'Cannot load the default-model list'],
'defaultModelError.unknownProvider': ['这个服务商当前没有可用模型。', 'That Provider has no available models right now.'],
'defaultModelError.unknownModel': ['这个服务商没有提供该模型。', 'That Provider does not offer that model.'],
'defaultModelError.unknownEffort': ['这个模型不支持该思考强度。', 'That model does not offer that reasoning effort.'],
'defaultModelError.unchanged': ['已经是默认模型。', 'That is already the default.'],
'defaultModelError.unsaved': ['Session 已切换,但 DSH 没有保存成新的默认模型;请重试或检查配置编辑权限。', 'The Session switched, but DSH did not save the new default; retry or check the configuration editor.'],
historyIntro: ['每次保存记一条;改变策略版本的保存(接单规则、各来源上的策略、接单模式)通知进行中的工单。', 'Each save is one entry; saves that change the Policy version (rules, the Policy on each Source, accept mode) notify open Tickets.'],