Files
dsh-superagent/tests/default-model.spec.ts
wandering1 17be661615 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.
2026-11-02 08:32:53 +08:00

172 lines
10 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 } : {} })
/** 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 => {
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`
* — 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 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', () => {
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('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')
})
})