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:
@@ -23,7 +23,7 @@ Both presets run with `danger-full-access` as the operator's OS user and read me
|
||||
- Node.js 22.19+ or 24+, pnpm, git 2.32+, Python 3.8+ and tmux
|
||||
- GitHub CLI (`gh`) 2.48 or later. Source CLI fields must point to `gh` or `lark-cli` wrappers; the host relies on their subcommands and output formats.
|
||||
- A repository on github.com; GitHub Enterprise Server is not supported. To create a checkout per Ticket, keep a local clone from which the prepare command can add worktrees. Install `git-lfs` if that repository uses Git LFS.
|
||||
- A model provider configured for DeepSeek Harness. dsh-superagent uses the profile's default model unless the Eval or Execution settings name another.
|
||||
- A model provider configured for DeepSeek Harness. dsh-superagent uses the profile's default model unless the Eval or Execution settings name another (optionally with a reasoning effort); a reused Session is re-pointed to its role's configured model on the next dispatch.
|
||||
- For Feishu: `lark-cli` (tested with 1.0.90) and a Feishu or Lark app with a bot
|
||||
|
||||
## Accounts
|
||||
|
||||
@@ -334,8 +334,8 @@ Some generic modules still refer directly to GitHub or Feishu:
|
||||
| `src/app/sessions.ts` | Sessions |
|
||||
| `src/app/delivery.ts` | `AgentDelivery`: delivers a message to a DeepSeek Harness agent once per request id, also across a restored Session |
|
||||
| `src/app/retry-health.ts` | `RetryHealth`: which Sessions wait on a model budget retry, for the `models` health entry |
|
||||
| `src/app/role-models.ts` | Resolves the Eval and Executor models, and seeds a new Session with its role's model |
|
||||
| `src/app/default-model.ts` | `switchDefaultModel`: changes the deployment default model through the Session Controller, which persists it into the profile, and refuses a route a new Session could not use |
|
||||
| `src/app/role-models.ts` | Resolves the Eval and Executor models: seeds a new Session with its role's model and re-points a reused Session whose durable route drifted |
|
||||
| `src/app/default-model.ts` | `switchDefaultModel`: changes the deployment default model, optionally with a reasoning effort the catalog offers, through the Session Controller, which persists it into the profile |
|
||||
| `src/app/assessment.ts` | The MasterAssessor, the assessment schema and the window `orchestrator_eval` answers |
|
||||
| `src/app/execution-environment.ts` | The execution environment: its variables, a Ticket's values, the directory root and the lines of the prompt section, which `src/app/prompts.ts` fills into its template |
|
||||
| `src/app/source-context.ts` | `sourceContextOf`: each Source kind's `SourceContext` |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,23 +35,47 @@ 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}
|
||||
{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 (option.provider === current.provider && option.model === current.model) { setOpen(false); return }
|
||||
void pick.run(option.provider, option.model)
|
||||
}}>{option.text}</button>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -11,10 +11,17 @@ const MASTER = 'master-1'
|
||||
/** One model route as the catalog reports it. */
|
||||
const selection = (provider: string, model: string, reasoningEffort?: string): ModelSelection =>
|
||||
({ provider, model, ...reasoningEffort ? { reasoningEffort } : {} })
|
||||
/** A catalog whose default is `current` and whose routable providers offer exactly `models`, keyed by provider id. */
|
||||
function catalog(current: ModelSelection, models: Record<string, string[]> = { host: ['default', 'next'] }): ModelCatalog {
|
||||
/** Reasoning metadata offered by an exact `provider/model` route, keyed by `${provider}/${model}`. */
|
||||
type ReasoningByRoute = Record<string, { efforts: { id: string; name: string; description?: string }[]; defaultEffort?: string }>
|
||||
/** A catalog whose default is `current` and whose routable providers offer exactly `models`, keyed by provider id;
|
||||
* `reasoning` attaches selectable-effort metadata to the exact `provider/model` routes it names. */
|
||||
function catalog(current: ModelSelection, models: Record<string, string[]> = { host: ['default', 'next'] },
|
||||
reasoning: ReasoningByRoute = {}): ModelCatalog {
|
||||
return { default: current, routableProviders: Object.keys(models), failures: [],
|
||||
groups: Object.entries(models).map(([id, ids]) => ({ id, name: id, models: ids.map(model => ({ id: model, name: model })) })) }
|
||||
groups: Object.entries(models).map(([id, ids]) => ({ id, name: id, models: ids.map(model => {
|
||||
const route = `${id}/${model}`
|
||||
return reasoning[route] ? { id: model, name: model, reasoning: reasoning[route] } : { id: model, name: model }
|
||||
}) })) }
|
||||
}
|
||||
/**
|
||||
* A fake Host context: `modelCatalog` keeps reporting `initial` until `selectModel` runs, when it starts reporting `saved`
|
||||
@@ -88,13 +95,60 @@ describe('default model switch', () => {
|
||||
})
|
||||
|
||||
it('forwards reasoning effort as a branded id and reports the switch saved only when it matches', async () => {
|
||||
const host = controller(catalog(selection('host', 'default')), catalog(selection('host', 'next', 'high')))
|
||||
const efforts = [{ id: 'high', name: 'High' }]
|
||||
const host = controller(catalog(selection('host', 'default'), { host: ['default', 'next'] }, { 'host/next': { efforts } }),
|
||||
catalog(selection('host', 'next', 'high'), { host: ['default', 'next'] }, { 'host/next': { efforts } }))
|
||||
await expect(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'high' }))
|
||||
.resolves.toEqual(selection('host', 'next', 'high'))
|
||||
const request = host.selectModel.mock.calls[0]?.[0]
|
||||
expect(request?.reasoningEffort).toBe(ReasoningEffortId('high'))
|
||||
expect(String(request?.reasoningEffort)).toBe('high')
|
||||
})
|
||||
|
||||
it('switches onto a model with a supported effort, branding the selectModel request', async () => {
|
||||
const efforts = [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }]
|
||||
const host = controller(catalog(selection('host', 'default'), { host: ['default', 'next'] }, { 'host/next': { efforts } }),
|
||||
catalog(selection('host', 'next', 'low')))
|
||||
await expect(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'low' }))
|
||||
.resolves.toEqual(selection('host', 'next', 'low'))
|
||||
expect(host.selectModel).toHaveBeenCalledOnce()
|
||||
expect(host.selectModel).toHaveBeenCalledWith({ sessionId: MASTER, provider: 'host', model: 'next', reasoningEffort: ReasoningEffortId('low') })
|
||||
})
|
||||
|
||||
it('refuses an effort the model does not offer, without touching the Session selection', async () => {
|
||||
const efforts = [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }]
|
||||
const host = controller(catalog(selection('host', 'default'), { host: ['default', 'next'] }, { 'host/next': { efforts } }))
|
||||
const failure = await refused(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'ultra' }))
|
||||
expect(failure.reason).toBe('unknownEffort')
|
||||
expect(host.selectModel).not.toHaveBeenCalled()
|
||||
expect(host.modelCatalog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refuses a reasoning effort for a model that offers none', async () => {
|
||||
const host = controller(catalog(selection('host', 'default')))
|
||||
const failure = await refused(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'low' }))
|
||||
expect(failure.reason).toBe('unknownEffort')
|
||||
expect(host.selectModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an effort change alone as a valid switch away from the model default', async () => {
|
||||
const efforts = [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }]
|
||||
const host = controller(catalog(selection('host', 'next', 'high'), { host: ['default', 'next'] }, { 'host/next': { efforts, defaultEffort: 'high' } }),
|
||||
catalog(selection('host', 'next', 'low')))
|
||||
await expect(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'low' }))
|
||||
.resolves.toEqual(selection('host', 'next', 'low'))
|
||||
expect(host.selectModel).toHaveBeenCalledTimes(1)
|
||||
expect(host.selectModel).toHaveBeenCalledWith({ sessionId: MASTER, provider: 'host', model: 'next', reasoningEffort: ReasoningEffortId('low') })
|
||||
})
|
||||
|
||||
it('refuses an unchanged route whose implicit effort settles on the current default', async () => {
|
||||
const efforts = [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }]
|
||||
const host = controller(catalog(selection('host', 'next', 'high'), { host: ['default', 'next'] }, { 'host/next': { efforts, defaultEffort: 'high' } }))
|
||||
const failure = await refused(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next' }))
|
||||
expect(failure.reason).toBe('unchanged')
|
||||
expect(host.selectModel).not.toHaveBeenCalled()
|
||||
expect(host.modelCatalog).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('default model refusal', () => {
|
||||
@@ -105,6 +159,11 @@ describe('default model refusal', () => {
|
||||
expect(defaultModelRefusal(t, failure)).toBe('[defaultModelError.unknownProvider]')
|
||||
})
|
||||
|
||||
it('localizes a refusal the host tagged with an unknownEffort code', () => {
|
||||
const failure = new ApiError('Model "host/next" does not offer effort "ultra"', 400, { code: 'defaultModel.unknownEffort' })
|
||||
expect(defaultModelRefusal(t, failure)).toBe('[defaultModelError.unknownEffort]')
|
||||
})
|
||||
|
||||
it('returns error text that names no defaultModel code unchanged', () => {
|
||||
const failure = new Error('Provider "ghost" has no available models')
|
||||
expect(defaultModelRefusal(t, failure)).toBe('Provider "ghost" has no available models')
|
||||
|
||||
Reference in New Issue
Block a user