/** 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 /** 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 = { 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): Promise { 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') }) })