Files
dsh-superagent/tests/default-model.spec.ts
T
wandering1 a662188a57 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.
2026-11-02 00:12:23 +08:00

113 lines
6.4 KiB
TypeScript

/** 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')
})
})