17be661615
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.
172 lines
10 KiB
TypeScript
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')
|
|
})
|
|
})
|