diff --git a/docs/007-architecture.md b/docs/007-architecture.md index 6b124b3..6cfc8d9 100644 --- a/docs/007-architecture.md +++ b/docs/007-architecture.md @@ -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 | diff --git a/src/app/api.ts b/src/app/api.ts index bc71d62..03c4694 100644 --- a/src/app/api.ts +++ b/src/app/api.ts @@ -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 } diff --git a/src/app/default-model.ts b/src/app/default-model.ts new file mode 100644 index 0000000..7ec8329 --- /dev/null +++ b/src/app/default-model.ts @@ -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.`. */ +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 { + 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)) + } +} diff --git a/src/app/http.ts b/src/app/http.ts index 13c0115..e055d87 100644 --- a/src/app/http.ts +++ b/src/app/http.ts @@ -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 @@ -87,6 +90,8 @@ function failure(error: unknown): [number, Record] { 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)) diff --git a/src/app/runtime.ts b/src/app/runtime.ts index a8cfe52..d3cf74e 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -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), diff --git a/src/client/api.ts b/src/client/api.ts index f58f44e..56e69e4 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -15,7 +15,9 @@ export interface UserFailure { code: UserErrorCode; values: Record(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'> diff --git a/src/client/app.tsx b/src/client/app.tsx index 3263005..3b45262 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -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)} /> + setGlossary(false)} /> diff --git a/src/client/client.css b/src/client/client.css index 7a1bf77..eb1ce86 100644 --- a/src/client/client.css +++ b/src/client/client.css @@ -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; } diff --git a/src/client/default-model.tsx b/src/client/default-model.tsx new file mode 100644 index 0000000..0632d12 --- /dev/null +++ b/src/client/default-model.tsx @@ -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 {t('defaultModelError.load')} + if (!catalog) return {t('loading')} + 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
+ + {open &&
    + {options.map(option =>
  • + +
  • )} +
} +
+} + +/** 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 +} diff --git a/src/client/locales.ts b/src/client/locales.ts index 123acba..1aa3c55 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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}'], diff --git a/src/client/provider.tsx b/src/client/provider.tsx index ec5bb57..c5b8935 100644 --- a/src/client/provider.tsx +++ b/src/client/provider.tsx @@ -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(action: (...args: A) => Promise, - { reload }: { reload?: () => Promise } = {}) { + { reload, failure: wording }: { reload?: () => Promise; failure?: (failure: unknown) => string } = {}) { const { notify } = usePanel() const [busy, setBusy] = useState(false) const running = useRef(false) @@ -49,7 +49,7 @@ export function useAction(action: (...args: A) => Promise + ({ 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 = { 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): 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 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') + }) +}) diff --git a/tests/fixtures/panel-server.ts b/tests/fixtures/panel-server.ts index 513d91a..3d30883 100644 --- a/tests/fixtures/panel-server.ts +++ b/tests/fixtures/panel-server.ts @@ -88,6 +88,8 @@ export interface PanelServer { joined?: Record; joining?: Record joinError?: string settings: Settings; revisions: Record + /** 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 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 } = { '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