Panel: switch the deployment default model from the header
The panel could show the DSH default model but not change it, so an operator who wanted a different Executor or Assessor model had to edit the profile by hand. PUT default-model routes a switch through the Session Controller, which is the same path the DSH picker uses: it retargets the master Session and lets agent-default-model persist the choice into the profile through configEditor. switchDefaultModel refuses a provider the catalog cannot route and a model its group does not offer, and reports unchanged rather than writing the same value again. selectModel saves the default in the background, so the switch polls the catalog until the saved selection appears instead of assuming the write landed; effort is compared against the selection the Controller resolved, because it fills in a default effort the caller never sent. The refusal carries its reason as a code, which ApiError now keeps so the panel can word it in the operator's language instead of showing host English.
This commit is contained in:
@@ -333,6 +333,7 @@ Some generic modules still refer directly to GitHub or Feishu:
|
||||
| `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/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` |
|
||||
@@ -381,6 +382,7 @@ Some generic modules still refer directly to GitHub or Feishu:
|
||||
| `src/client/index.tsx` | The client plugin entry: the sidebar entry, the main panel and the page in DeepSeek Harness's Plugin Manager |
|
||||
| `src/client/app.tsx` | The panel: header, search, glossary, tabs and the page of the current route |
|
||||
| `src/client/provider.tsx` | What every page reads from its host: copy, calls, navigation, notices, the state poll, and `restart`, which reads the state quickly while dsh-superagent restarts |
|
||||
| `src/client/default-model.tsx` | The header's default-model switcher, and how a refused switch is worded |
|
||||
| `src/client/api.ts` | Calls and polls of the management API, typed by `src/app/api.ts`, and the Decision filters of the Eval status |
|
||||
| `src/client/route.ts` | The tabs, the settings groups, the typed routes that `parseRoute` reads, and the route memory with the settings group opened last |
|
||||
| `src/client/overview.tsx` | The overview |
|
||||
@@ -413,6 +415,7 @@ Some generic modules still refer directly to GitHub or Feishu:
|
||||
| `tests/registry.spec.ts` | `SourceRegistry`: instances, validation, Policy parts, Channels and Thread context |
|
||||
| `tests/sources.spec.ts` | The Feishu and GitHub Sources on a `SourceContext`: translation, polls and read positions, the pull request rules, health |
|
||||
| `tests/gitea.spec.ts` | The Gitea Source on a `SourceContext` against a fetched stub of the Gitea API: translation, notification and stream polls, cursors, the Channel, health |
|
||||
| `tests/default-model.spec.ts` | The default-model switcher: the routes it refuses, the switch it makes through the Session Controller, and the wording of a refusal |
|
||||
| `tests/commands.spec.ts` | CLI calls as the deployment's accounts, the Channels' answers, and repository-scoped GitHub calls |
|
||||
| `tests/feishu-read.spec.ts`, `tests/github-read.spec.ts` | Feishu and GitHub reads |
|
||||
| `tests/admission-context.spec.ts`, `tests/feishu-image.spec.ts` | The Thread window of assessments, and Feishu image reads |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Type-only management API contract between the web panel and host routes, imported by the panel without bundling host code. */
|
||||
import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { RoleModel } from '../policy.ts'
|
||||
import type { PromptConfig, Prompts } from './prompts.ts'
|
||||
import type { PromptsView, StateView, TicketPageView } from './http.ts'
|
||||
import type { HistoryView } from './settings-history.ts'
|
||||
@@ -40,6 +41,7 @@ export interface Api {
|
||||
'POST policy-sync/:id': { response: { policy: unknown } }
|
||||
'POST setup/check': { response: { restarting: true } }
|
||||
'GET models': { response: ModelCatalog }
|
||||
'PUT default-model': { body: RoleModel; response: { selected: ModelCatalog['default'] } }
|
||||
'GET prompts': { query?: { revision?: number; offset?: number }; response: PromptsView }
|
||||
'PUT prompts': { body: { config: PromptConfig; baseRevision: number }; response: { revision: number; context_delivery: Delivery } }
|
||||
'POST prompts/preview': { body: PromptConfig; response: ReturnType<Prompts['preview']> }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Reads and switches the DSH deployment-wide default model (`agentDefaultModel`) through the Session Controller.
|
||||
*
|
||||
* The default is not owned by this plugin: `@deepseek-ai/dsh-agent-default-model` owns it and persists a change into
|
||||
* the profile's `cordis.patch.yml` through `configEditor`, which Loader hot-reloads. Reading goes through the catalog so
|
||||
* the panel sees exactly what a new Session would inherit; switching goes through `sessionController.selectModel` on the
|
||||
* master Session, which is the same path the DSH model picker uses and therefore also saves the default.
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
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) {
|
||||
super(detail ?? reason)
|
||||
}
|
||||
}
|
||||
|
||||
/** The owner writes the profile through the config editor asynchronously; poll its reload before reporting success. */
|
||||
const SAVE_TIMEOUT_MS = 5_000
|
||||
const SAVE_POLL_MS = 100
|
||||
|
||||
/** Reject a route a brand-new Session could not use, by consulting the same catalog the picker renders. */
|
||||
function checked(catalog: ModelCatalog, provider: string, model: string): void {
|
||||
if (!catalog.routableProviders.includes(provider)) {
|
||||
throw new DefaultModelError('unknownProvider', `Provider "${provider}" has no available models`)
|
||||
}
|
||||
if (!catalog.groups.find(group => group.id === provider)?.models.some(item => item.id === model)) {
|
||||
throw new DefaultModelError('unknownModel', `Provider "${provider}" does not offer model "${model}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Two selections agree when provider, model, and effect-resolved effort agree. */
|
||||
const sameSelection = (left: ModelCatalog['default'], right: ModelCatalog['default']): boolean =>
|
||||
left.provider === right.provider && left.model === right.model
|
||||
&& (left.reasoningEffort ?? '') === (right.reasoningEffort ?? '')
|
||||
|
||||
/**
|
||||
* Switch the deployment default model and let its owner persist it.
|
||||
*
|
||||
* `selectModel` also installs the choice on the master Session, which keeps the panel's own conversation on the model the
|
||||
* operator just picked instead of leaving it on the previous one.
|
||||
* @param ctx - Host context providing `sessionController`.
|
||||
* @param masterId - Orchestrator master Session whose selection is retargeted alongside the default.
|
||||
* @param input - Requested provider, model, and optional reasoning effort.
|
||||
* @returns the default selection now in effect.
|
||||
*/
|
||||
export async function switchDefaultModel(ctx: Context, masterId: string, input: unknown): Promise<ModelCatalog['default']> {
|
||||
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 unchanged = catalog.default.provider === wanted.provider && catalog.default.model === wanted.model
|
||||
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,
|
||||
model: wanted.model, ...wanted.reasoningEffort ? { reasoningEffort: ReasoningEffortId(wanted.reasoningEffort) } : {} })
|
||||
// selectModel saves the default in the background, so wait for the config editor write instead of reading once.
|
||||
const deadline = Date.now() + SAVE_TIMEOUT_MS
|
||||
for (;;) {
|
||||
const after = await ctx.sessionController.modelCatalog()
|
||||
if (sameSelection(after.default, selected)) return after.default
|
||||
if (Date.now() >= deadline) {
|
||||
throw new DefaultModelError('unsaved', 'The Session switched, but DSH did not persist the new default')
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, SAVE_POLL_MS))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { z } from 'zod'
|
||||
import { THREAD_FILTERS, TICKET_FILTERS, type Api, type ApiResponse, type RouteParams } from './api.ts'
|
||||
import { sourceSkills } from './context.ts'
|
||||
import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { DefaultModelError, switchDefaultModel } from './default-model.ts'
|
||||
import { PromptConflict, SKILL_NAME, SKILL_PREFIX, TEAM_SECTIONS, builtInBase, builtInParts, defaultPrompts, savePrompts, type Prompts }
|
||||
from './prompts.ts'
|
||||
import { policyVersion } from '../policy.ts'
|
||||
@@ -29,6 +30,8 @@ import { decisionList, search, threadDetail, threadList } from './views/threads.
|
||||
import { executorLoad, ticketDetail, ticketList } from './views/tickets.ts'
|
||||
|
||||
export interface HttpHost {
|
||||
/** Host context, used by routes that drive a DSH service directly (the default-model switch). */
|
||||
readonly context: Context
|
||||
readonly store: Store
|
||||
/** Feishu message links resolved since startup, used by search to match AppLink URLs. */
|
||||
readonly sourceLinks: Pick<TextCache, 'entries'>
|
||||
@@ -87,6 +90,8 @@ function failure(error: unknown): [number, Record<string, unknown>] {
|
||||
if (error instanceof RouteError) return [error.status, { error: error.message }]
|
||||
if (error instanceof UserError) return [error.missing ? 404 : 400, { error: error.message, code: error.code, values: error.values }]
|
||||
if (error instanceof SettingsConflict || error instanceof PromptConflict) return [409, { error: error.message }]
|
||||
// A refused default-model switch is a client-correctable choice, not a server fault.
|
||||
if (error instanceof DefaultModelError) return [400, { error: error.message, code: `defaultModel.${error.reason}` }]
|
||||
return [500, { error: error instanceof Error ? error.message : String(error) }]
|
||||
}
|
||||
|
||||
@@ -204,6 +209,7 @@ function routes(runtime: HttpHost): Routes {
|
||||
'POST policy-sync/:id': async ({ params }) => ({ policy: await runtime.settings.sync(params.id) }),
|
||||
'POST setup/check': () => { runtime.checkSetup(); return { restarting: true } },
|
||||
'GET models': () => runtime.models(),
|
||||
'PUT default-model': async ({ body }) => ({ selected: await switchDefaultModel(runtime.context, runtime.masterId, await body()) }),
|
||||
'GET prompts': ({ query }) => { const { revision, offset } = PromptsQuery.parse(query); return promptsView(runtime, revision, offset) },
|
||||
'PUT prompts': async input => {
|
||||
const request = z.object({ config: z.unknown(), baseRevision: z.number().int().nonnegative() }).parse(await promptBody(input))
|
||||
|
||||
+1
-1
@@ -219,7 +219,7 @@ export class Runtime extends Service implements SourceHost {
|
||||
private httpHost(): HttpHost {
|
||||
const runtime = this
|
||||
const { store, sources, prompts, sessions, settings, engine, executor, health, machine, masterId, ready, sourceLinks, setup, passes } = this
|
||||
return { store, sources, prompts, sessions, settings, engine, executor, health, machine, masterId, ready, sourceLinks,
|
||||
return { context: this.ctx, store, sources, prompts, sessions, settings, engine, executor, health, machine, masterId, ready, sourceLinks,
|
||||
get deployment() { return setup.deployment },
|
||||
get setupProblem() { return setup.problem }, get feishuProblem() { return setup.feishuProblem },
|
||||
configured: () => setup.configured(), links: () => runtime.links(), ticketEnvironment: ticket => runtime.ticketEnvironment(ticket),
|
||||
|
||||
+5
-1
@@ -15,7 +15,9 @@ export interface UserFailure { code: UserErrorCode; values: Record<string, strin
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, readonly status: number,
|
||||
readonly details: { issues?: { path: string; message: string }[]; setup?: SetupProblem; user?: UserFailure; unreachable?: boolean } = {}) {
|
||||
readonly details: { issues?: { path: string; message: string }[]; setup?: SetupProblem; user?: UserFailure; unreachable?: boolean
|
||||
/** The host's error code when it names a failure the panel words itself, such as `defaultModel.unchanged`. */
|
||||
code?: string } = {}) {
|
||||
super(message)
|
||||
}
|
||||
get issues() { return this.details.issues ?? [] }
|
||||
@@ -52,6 +54,7 @@ async function api<T>(messages: ApiMessages, path: string, method: string, body?
|
||||
: typeof failure.error === 'string' ? failure.error : !text && status === 404 ? messages.notRunning
|
||||
: status >= 500 ? messages.serverError(status) : messages.requestFailed(status)
|
||||
throw new ApiError(message, status, { issues, ...failure.setup ? { setup: failure.setup } : {}, ...user ? { user } : {},
|
||||
...typeof failure.code === 'string' && !isUserError(failure.code) ? { code: failure.code } : {},
|
||||
...text ? {} : { unreachable: true } })
|
||||
}
|
||||
return value as T
|
||||
@@ -116,6 +119,7 @@ export function caller(messages: () => ApiMessages): Call {
|
||||
}
|
||||
}
|
||||
|
||||
export type { ApiResponse }
|
||||
export type StateView = ApiResponse<'GET state'>
|
||||
export type SetupView = StateView['setup']
|
||||
export type SettingsView = ApiResponse<'GET settings'>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import type { SearchView } from './api.ts'
|
||||
import { Button, Dot, Icon, Input, Modal, SegmentedTabs } from './controls.tsx'
|
||||
import { DefaultModelPicker } from './default-model.tsx'
|
||||
import type { DeploymentForm } from './deployment.tsx'
|
||||
import { EvalStatus } from './eval.tsx'
|
||||
import { ExecutionStatus } from './execution.tsx'
|
||||
@@ -133,6 +134,7 @@ function Header({ masterId }: { masterId: string | undefined }) {
|
||||
onClick={() => setGlossary(true)} />
|
||||
<Button variant="outline" size="sm" icon={<Icon name="session" size={14} />} disabled={!masterId}
|
||||
onClick={() => { if (masterId) openSession(masterId).catch(failure => notify(errorText(failure))) }}>{t('openMaster')}</Button>
|
||||
<DefaultModelPicker />
|
||||
</div>
|
||||
<Glossary open={glossary} onClose={() => setGlossary(false)} />
|
||||
</header>
|
||||
|
||||
@@ -380,3 +380,12 @@ a.dt-btn, a.dt-btn:hover { text-decoration: none; }
|
||||
.dt-admission-slot { display: inline-flex; flex: none; width: 16px; }
|
||||
.dt-info-warn { color: var(--dsw-alias-state-warn-primary); }
|
||||
.dt-field-segmented { max-width: 240px; }
|
||||
|
||||
/* Header default-model switcher: the trigger sits in the toolbar, the menu drops under it. */
|
||||
.dt-model-picker { position: relative; display: flex; align-items: center; }
|
||||
.dt-model-picker > .dt-btn { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dt-model-menu { position: absolute; top: calc(100% + 6px); right: 0; min-width: 260px; max-width: min(420px, 90cqw); max-height: 50vh; overflow: auto; margin: 0; padding: 6px; list-style: none; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 12px; background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-elevation-prominent); z-index: 6; display: flex; flex-direction: column; gap: 2px; }
|
||||
.dt-model-option { display: block; width: 100%; box-sizing: border-box; padding: 6px 10px; border: 0; border-radius: 8px; text-align: left; font: inherit; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary); background: transparent; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { ApiError, usePoll, type ApiResponse } from './api.ts'
|
||||
import { Icon } from './controls.tsx'
|
||||
import { useAction, usePanel, usePanelData } from './provider.tsx'
|
||||
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}`
|
||||
|
||||
export function DefaultModelPicker() {
|
||||
const { t, call } = usePanel()
|
||||
const { reload: reloadData } = usePanelData()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { data: catalog, error, reload } = usePoll(() => call('GET models'), [], 30_000)
|
||||
const pick = useAction(async (provider: string, model: string) => {
|
||||
setOpen(false)
|
||||
await call('PUT default-model', { body: { provider, model } })
|
||||
await reload()
|
||||
await reloadData()
|
||||
}, { failure: failure => defaultModelRefusal(t, failure) })
|
||||
if (error) return <span className="dt-model-picker dt-model-picker-error" role="status">{t('defaultModelError.load')}</span>
|
||||
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}` })))
|
||||
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>)}
|
||||
</ul>}
|
||||
</div>
|
||||
}
|
||||
|
||||
/** Localize a refused switch; the host names the reason in the error code and the panel decides the wording. */
|
||||
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
|
||||
}
|
||||
@@ -404,6 +404,13 @@ const TEXT = {
|
||||
modelInherit: ['继承 DSH 默认值', 'Inherit the DSH default'],
|
||||
modelEffortDefault: ['模型默认', 'Model default'],
|
||||
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.'],
|
||||
'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.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.'],
|
||||
historyItem: ['第 {revision} 次保存 · {source}', 'Save {revision} · {source}'],
|
||||
historyPolicyChanged: ['生成策略版本 {version}', 'New Policy version {version}'],
|
||||
|
||||
@@ -40,7 +40,7 @@ export function usePanelData(): PanelData {
|
||||
}
|
||||
/** Wrap an async action with re-entrancy protection, busy state, optional reload, and error toast reporting. */
|
||||
export function useAction<A extends unknown[]>(action: (...args: A) => Promise<unknown>,
|
||||
{ reload }: { reload?: () => Promise<unknown> } = {}) {
|
||||
{ reload, failure: wording }: { reload?: () => Promise<unknown>; failure?: (failure: unknown) => string } = {}) {
|
||||
const { notify } = usePanel()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const running = useRef(false)
|
||||
@@ -49,7 +49,7 @@ export function useAction<A extends unknown[]>(action: (...args: A) => Promise<u
|
||||
running.current = true
|
||||
setBusy(true)
|
||||
try { await action(...args); await reload?.(); return true }
|
||||
catch (failure) { notify(errorText(failure)); return false }
|
||||
catch (failure) { notify(wording ? wording(failure) : errorText(failure)); return false }
|
||||
finally { running.current = false; setBusy(false) }
|
||||
}
|
||||
return { run, busy }
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/** Default-model switching: catalog checks, refusal reasons, the persisted-default poll, and localized refusals. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ModelCatalog, ModelSelection, SessionSelectModelRequest } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DefaultModelError, switchDefaultModel } from '../src/app/default-model.ts'
|
||||
import { ApiError } from '../src/client/api.ts'
|
||||
import { defaultModelRefusal } from '../src/client/default-model.tsx'
|
||||
|
||||
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 {
|
||||
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 })) })) }
|
||||
}
|
||||
/**
|
||||
* A fake Host context: `modelCatalog` keeps reporting `initial` until `selectModel` runs, when it starts reporting `saved`
|
||||
* — the asynchronous config-editor write `switchDefaultModel` polls for. Omitting `saved` keeps the default stale, as it
|
||||
* stays when DSH never persists the switch. `selectModel` resolves the accepted selection onto the Session and returns it,
|
||||
* which is what the switch compares the persisted default against.
|
||||
*/
|
||||
function controller(initial: ModelCatalog, saved?: ModelCatalog) {
|
||||
let current = initial
|
||||
const modelCatalog = vi.fn(async () => current)
|
||||
const selectModel = vi.fn(async (request: SessionSelectModelRequest) => {
|
||||
if (saved) current = saved
|
||||
return { selected: selection(request.provider, request.model,
|
||||
request.reasoningEffort === undefined ? undefined : String(request.reasoningEffort)) }
|
||||
})
|
||||
const ctx = { sessionController: { modelCatalog, selectModel } } as unknown as Context
|
||||
return { ctx, modelCatalog, selectModel }
|
||||
}
|
||||
/** Await a refusal and return it, failing the test when the call resolves or fails with another error. */
|
||||
async function refused(promise: Promise<unknown>): Promise<DefaultModelError> {
|
||||
const outcome = await promise.then(() => undefined, (failure: unknown) => failure)
|
||||
expect(outcome).toBeInstanceOf(DefaultModelError)
|
||||
return outcome as DefaultModelError
|
||||
}
|
||||
|
||||
describe('default model switch', () => {
|
||||
it('selects the model on the master Session and returns the default the catalog then reports', async () => {
|
||||
const host = controller(catalog(selection('host', 'default')), catalog(selection('host', 'next')))
|
||||
await expect(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next' }))
|
||||
.resolves.toEqual(selection('host', 'next'))
|
||||
expect(host.selectModel).toHaveBeenCalledOnce()
|
||||
expect(host.selectModel).toHaveBeenCalledWith({ sessionId: MASTER, provider: 'host', model: 'next' })
|
||||
})
|
||||
|
||||
it('refuses a provider no model routes to, without touching the Session selection', async () => {
|
||||
const host = controller(catalog(selection('other', 'one'), { other: ['one'] }))
|
||||
const failure = await refused(switchDefaultModel(host.ctx, MASTER, { provider: 'ghost', model: 'one' }))
|
||||
expect(failure.reason).toBe('unknownProvider')
|
||||
expect(host.selectModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a model the routable provider does not offer', async () => {
|
||||
const host = controller(catalog(selection('host', 'default'), { host: ['default'] }))
|
||||
const failure = await refused(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'missing' }))
|
||||
expect(failure.reason).toBe('unknownModel')
|
||||
expect(host.selectModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses the default already in effect after a single catalog read', async () => {
|
||||
const host = controller(catalog(selection('host', 'default')))
|
||||
const failure = await refused(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'default' }))
|
||||
expect(failure.reason).toBe('unchanged')
|
||||
expect(host.selectModel).not.toHaveBeenCalled()
|
||||
expect(host.modelCatalog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports unsaved after polling when DSH never persists the new default', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
try {
|
||||
const host = controller(catalog(selection('host', 'default')))
|
||||
const settled = switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next' })
|
||||
.then(() => undefined, (failure: unknown) => failure)
|
||||
// Each poll waits 100ms; the 5s save timeout expires after ~50 reads of a default that never changes.
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
const failure = await settled
|
||||
expect(failure).toBeInstanceOf(DefaultModelError)
|
||||
expect((failure as DefaultModelError).reason).toBe('unsaved')
|
||||
expect(host.selectModel).toHaveBeenCalledOnce()
|
||||
expect(host.modelCatalog.mock.calls.length).toBeGreaterThan(1)
|
||||
} finally { vi.useRealTimers() }
|
||||
})
|
||||
|
||||
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')))
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default model refusal', () => {
|
||||
const t = (key: string) => `[${key}]`
|
||||
|
||||
it('localizes a refusal the host tagged with a defaultModel code', () => {
|
||||
const failure = new ApiError('Provider "ghost" has no available models', 400, { code: 'defaultModel.unknownProvider' })
|
||||
expect(defaultModelRefusal(t, failure)).toBe('[defaultModelError.unknownProvider]')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
Vendored
+8
-1
@@ -88,6 +88,8 @@ export interface PanelServer {
|
||||
joined?: Record<string, string>; joining?: Record<string, string>
|
||||
joinError?: string
|
||||
settings: Settings; revisions: Record<string, number>
|
||||
/** Deployment default model as last reported by `GET models` or written by `PUT default-model`. */
|
||||
defaultModel: ApiResponse<'GET models'>['default']
|
||||
prompts: PromptsView
|
||||
tickets: { open: TicketRow[]; ended: TicketRow[] }; ticketDetails: Record<string, TicketPageView>
|
||||
threads: ThreadRow[]
|
||||
@@ -252,6 +254,7 @@ function initial(): PanelServer {
|
||||
accounts: { repository: 'acme/widgets', agent: 'agent-bot', feishu: { appId: 'cli_bot', brand: 'feishu', botId: 'ou_bot',
|
||||
name: 'Widgets Bot' } } },
|
||||
settings, revisions: { policy: 3, eval: 1, executor: 0 },
|
||||
defaultModel: { provider: 'host', model: 'default' },
|
||||
prompts: { masterId: 'master', sections: TEAM_SECTIONS, skillName: SKILL_NAME.source, builtin: ['feishu', 'github'],
|
||||
skillPrefix: SKILL_PREFIX, revision: 0, current: { ...defaults, config: defaultPrompts() },
|
||||
defaults: defaultPrompts(), parts: Object.entries(builtInParts()).map(([key, part]) => ({ key, ...part, base: builtInBase(part.text) })),
|
||||
@@ -369,9 +372,13 @@ const ROUTES: { [K in keyof Api]: Route<K> } = {
|
||||
'GET policy/history': { answer: () => server.history },
|
||||
'POST policy-sync/:id': { answer: ({ params }) => ({ policy: server.settings.policy.sources[params.id] }) },
|
||||
'POST setup/check': { answer: () => ({ restarting: true }) },
|
||||
'GET models': { answer: () => ({ default: { provider: 'host', model: 'default' }, routableProviders: [], failures: [],
|
||||
'GET models': { answer: () => ({ default: server.defaultModel, routableProviders: ['worker-provider'], failures: [],
|
||||
groups: [{ id: 'worker-provider', name: 'Worker provider', models: [{ id: 'worker-model', name: 'Worker model',
|
||||
reasoning: { efforts: [{ id: 'high', name: 'High' }] } }] }] }) },
|
||||
'PUT default-model': { body: true, answer: ({ body }) => {
|
||||
server.defaultModel = { provider: body.provider, model: body.model, ...body.reasoningEffort ? { reasoningEffort: body.reasoningEffort } : {} }
|
||||
return { selected: server.defaultModel }
|
||||
} },
|
||||
'GET prompts': { query: ['revision', 'offset'], answer: () => server.prompts },
|
||||
'PUT prompts': { body: true, answer: ({ body }) => {
|
||||
const { revision } = server.prompts
|
||||
|
||||
Reference in New Issue
Block a user