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
+1 -1
View File
@@ -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 - 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. - 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 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 - For Feishu: `lark-cli` (tested with 1.0.90) and a Feishu or Lark app with a bot
## Accounts ## Accounts
+2 -2
View File
@@ -334,8 +334,8 @@ Some generic modules still refer directly to GitHub or Feishu:
| `src/app/sessions.ts` | Sessions | | `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/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/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/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 through the Session Controller, which persists it into the profile, and refuses a route a new Session could not use | | `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/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/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` | | `src/app/source-context.ts` | `sourceContextOf`: each Source kind's `SourceContext` |
+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>`. */ /** Why a default-model switch was refused, localized by the panel through `defaultModelError.<reason>`. */
export class DefaultModelError extends Error { 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) super(detail ?? reason)
} }
} }
@@ -52,8 +52,14 @@ export async function switchDefaultModel(ctx: Context, masterId: string, input:
const wanted = RoleModelSchema.parse(input) const wanted = RoleModelSchema.parse(input)
const catalog = await ctx.sessionController.modelCatalog() const catalog = await ctx.sessionController.modelCatalog()
checked(catalog, wanted.provider, wanted.model) 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 const unchanged = catalog.default.provider === wanted.provider && catalog.default.model === wanted.model
&& (catalog.default.reasoningEffort ?? '') === (predicted ?? '')
if (unchanged) throw new DefaultModelError('unchanged') 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. // 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, 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: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-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-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. * 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 * 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 { useState } from 'react'
import { ApiError, usePoll, type ApiResponse } from './api.ts' import { ApiError, usePoll, type ApiResponse } from './api.ts'
@@ -13,16 +15,19 @@ import type { T } from './text.ts'
export type DefaultModelCatalog = ApiResponse<'GET models'> export type DefaultModelCatalog = ApiResponse<'GET models'>
/** Render a selection the way the operator reads one elsewhere in the panel. */ /** 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() { export function DefaultModelPicker() {
const { t, call } = usePanel() const { t, call } = usePanel()
const { reload: reloadData } = usePanelData() const { reload: reloadData } = usePanelData()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState<string>()
const { data: catalog, error, reload } = usePoll(() => call('GET models'), [], 30_000) 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) 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 reload()
await reloadData() await reloadData()
}, { failure: failure => defaultModelRefusal(t, failure) }) }, { 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> if (!catalog) return <span className="dt-model-picker dt-hint">{t('loading')}</span>
const current = catalog.default const current = catalog.default
const options = catalog.groups.flatMap(group => group.models.map(model => 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"> return <div className="dt-model-picker">
<button type="button" className="dt-btn dt-btn-outline dt-btn-sm" aria-haspopup="listbox" aria-expanded={open} <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)}> title={t('defaultModelQuickTip')} disabled={pick.busy} onClick={() => setOpen(value => !value)}>
<Icon name="settings" size={14} />{t('defaultModelQuick')}:{selection(current)} <Icon name="settings" size={14} />{t('defaultModelQuick')}:{selection(current)}
</button> </button>
{open && <ul className="dt-model-menu" role="listbox" aria-label={t('defaultModelQuick')}> {open && <ul className="dt-model-menu" role="listbox" aria-label={t('defaultModelQuick')}>
{options.map(option => <li key={`${option.provider}/${option.model}`}> {options.map(option => {
<button type="button" role="option" className="dt-model-option" disabled={pick.busy} const key = `${option.provider}/${option.model.id}`
aria-selected={option.provider === current.provider && option.model === current.model} const isCurrent = option.provider === current.provider && option.model.id === current.model
onClick={() => { const efforts = option.model.reasoning?.efforts ?? []
// Picking the current default would be refused by the host; just close, so a mis-click never toasts. return <li key={key}>
if (option.provider === current.provider && option.model === current.model) { setOpen(false); return } <button type="button" role="option" className="dt-model-option" disabled={pick.busy} aria-selected={isCurrent}
void pick.run(option.provider, option.model) aria-expanded={efforts.length ? expanded === key : undefined}
}}>{option.text}</button> // A model without efforts switches on its own row; one with efforts only expands, so the effort never stays ambiguous.
</li>)} 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>} </ul>}
</div> </div>
} }
@@ -55,5 +84,5 @@ export function defaultModelRefusal(t: T, failure: unknown): string {
const code = failure instanceof ApiError ? failure.details.code : undefined const code = failure instanceof ApiError ? failure.details.code : undefined
const reason = code?.startsWith('defaultModel.') ? code.slice('defaultModel.'.length) : undefined const reason = code?.startsWith('defaultModel.') ? code.slice('defaultModel.'.length) : undefined
const text = failure instanceof Error ? failure.message : String(failure) 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}'], modelCatalogError: ['无法加载模型建议,仍可填写已知的服务商和模型:{error}', 'Model suggestions are unavailable; known Provider and Model names still work: {error}'],
defaultModelQuick: ['默认模型', 'Default model'], defaultModelQuick: ['默认模型', 'Default model'],
defaultModelQuickTip: ['切换后,之后新建的执行器与研判 Session 都继承这个默认模型;已有 Session 保持原选择。', 'Switching changes what new Executor and Assessor Sessions inherit; existing Sessions keep their choice.'], 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.load': ['无法读取默认模型列表', 'Cannot load the default-model list'],
'defaultModelError.unknownProvider': ['这个服务商当前没有可用模型。', 'That Provider has no available models right now.'], 'defaultModelError.unknownProvider': ['这个服务商当前没有可用模型。', 'That Provider has no available models right now.'],
'defaultModelError.unknownModel': ['这个服务商没有提供该模型。', 'That Provider does not offer that model.'], '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.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.'], '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.'], historyIntro: ['每次保存记一条;改变策略版本的保存(接单规则、各来源上的策略、接单模式)通知进行中的工单。', 'Each save is one entry; saves that change the Policy version (rules, the Policy on each Source, accept mode) notify open Tickets.'],
+63 -4
View File
@@ -11,10 +11,17 @@ const MASTER = 'master-1'
/** One model route as the catalog reports it. */ /** One model route as the catalog reports it. */
const selection = (provider: string, model: string, reasoningEffort?: string): ModelSelection => const selection = (provider: string, model: string, reasoningEffort?: string): ModelSelection =>
({ provider, model, ...reasoningEffort ? { reasoningEffort } : {} }) ({ provider, model, ...reasoningEffort ? { reasoningEffort } : {} })
/** A catalog whose default is `current` and whose routable providers offer exactly `models`, keyed by provider id. */ /** Reasoning metadata offered by an exact `provider/model` route, keyed by `${provider}/${model}`. */
function catalog(current: ModelSelection, models: Record<string, string[]> = { host: ['default', 'next'] }): ModelCatalog { 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: [], 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` * 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 () => { 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' })) await expect(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'high' }))
.resolves.toEqual(selection('host', 'next', 'high')) .resolves.toEqual(selection('host', 'next', 'high'))
const request = host.selectModel.mock.calls[0]?.[0] const request = host.selectModel.mock.calls[0]?.[0]
expect(request?.reasoningEffort).toBe(ReasoningEffortId('high')) expect(request?.reasoningEffort).toBe(ReasoningEffortId('high'))
expect(String(request?.reasoningEffort)).toBe('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', () => { describe('default model refusal', () => {
@@ -105,6 +159,11 @@ describe('default model refusal', () => {
expect(defaultModelRefusal(t, failure)).toBe('[defaultModelError.unknownProvider]') 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', () => { it('returns error text that names no defaultModel code unchanged', () => {
const failure = new Error('Provider "ghost" has no available models') const failure = new Error('Provider "ghost" has no available models')
expect(defaultModelRefusal(t, failure)).toBe('Provider "ghost" has no available models') expect(defaultModelRefusal(t, failure)).toBe('Provider "ghost" has no available models')