feat(web): support reasoning effort selection

This commit is contained in:
Yichen Jiang
2026-07-27 16:12:40 +08:00
parent 39c60d5d64
commit 0d084ab6ff
26 changed files with 628 additions and 180 deletions

View File

@@ -10,7 +10,8 @@ export type {
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -46,6 +46,25 @@ const MARKDOWN_FIXTURE = [
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
defaultEffort: 'high',
}
const OPENAI_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'medium', name: 'Medium' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
defaultEffort: 'medium',
}
function sid(id: string): SessionId {
return id as SessionId
}
@@ -666,20 +685,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
description: '快速响应',
reasoning: DEEPSEEK_REASONING,
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
description: '复杂任务',
reasoning: DEEPSEEK_REASONING,
},
],
},
{
id: 'openai',
name: 'OpenAI',
models: [{ id: 'gpt-5', name: 'GPT-5' }],
models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }],
},
],
failures: [],
}),
selectModel: (request) => {
const selected = { provider: request.payload.provider, model: request.payload.model }
const selected: ModelTarget = {
provider: request.payload.provider,
model: request.payload.model,
...request.payload.reasoningEffort === undefined
? {}
: { reasoningEffort: request.payload.reasoningEffort },
}
modelTargets.set(request.payload.sessionId, selected)
return ok(request, { selected })
},
@@ -718,7 +753,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
userText === 'render markdown'
? MARKDOWN_FIXTURE
: userText === 'report model'
? `当前模型:${modelTargets.get(id)?.provider ?? 'unknown'}/${modelTargets.get(id)?.model ?? 'unknown'}`
? (() => {
const target = modelTargets.get(id)
return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}`
+ (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`)
})()
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
)
return ok(request, { accepted: true as const })

View File

@@ -15,7 +15,8 @@ export type {
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
README.md: 4de0e787a79d28ef8e92ee757d8d366e964c74d3
README.zh.md: 0eef4af91c69fdab1e04377244d6f256399caa75
README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642
README.zh.md: 325b1d93d99ed22e0945c26f5a3a9e5b3b209c85

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat (a compact trigger + upward provider-grouped menu, figma 313:14108's ToggleButton chrome) both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance — the host-reported current target is the single fact both surfaces echo, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter (an older response never overwrites a newer one); a connection reset drops every resident projection and repulls the Host-restored target before displaying it again. Provider-local catalog failures list inline while usable groups stay selectable; whole-request and selection failures surface on each entry's own retry face (the popup shell's error/retry, the seat menu's inline error) without forking the state. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope.
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope.
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
## Model Experience
Indirectly, through the `session.selectModel` RPC both entries submit: the host snapshots the selected provider/model pair at the next prompt-assembly boundary, so the following request routes (and stamps its prompt variables) with the chosen target while a running step keeps its assembled one — the directory, both menus, and every selection interaction stay client-side and never enter the session log.
Indirectly, through the `session.selectModel` RPC both entries submit: the Host snapshots the selected provider/model/reasoning target at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target. The selection becomes durable only when the existing request header records a request that consumes it; menu interaction adds no prompt content.
#### KV Cache effect
@@ -17,5 +17,5 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse
## Known Limitations and Deferred Work
- **No create-time selection** — both entries address an existing session's agent; there is no draft-phase model choice to fold into session creation (the seed order at the host's `targetFor` documents where such a tier would go).
- **Directory names are presentation-only** — selection and persistence use provider/model ids; a provider whose catalog lookup fails lists as an unselectable failure row until reload.
- **The seat shows no effort level** — the figma mock's `High` text has no wire concept behind it yet; the trigger renders the model name alone.
- **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload.
- **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent.

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService``ctx.models`)持有。`/model` popupSelect contribution`ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位(紧凑触发器 + 向上展开的按提供方分组菜单,视觉取 figma 313:14108 的 ToggleButton都通过同一个 `ModelDirectory` 实例经 `session.models` 加载会话的建议目录`session.selectModel` 提交——host 报告的 current target 是两个界面共同回显的唯一事实,在任一入口切换,另一入口下次打开显示的就是新值。目录加载与选择共享一个代次计数器旧响应不覆盖新结果;连接重置会丢弃所有常驻目录投影,重新拉取 Host 恢复的 target 后显示,避免继续呈现未消费的进程内选择。提供方级目录失败内联列出,可用分组保持可选整体失败与选择失败落各入口自己的重试面popup 壳的 error/retry、坑位菜单的内联错误状态不分叉。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。
模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService``ctx.models`)持有。`/model` popupSelect contribution`ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例`session.models` 加载会话的建议目录,并`session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单模型仍按提供方分组所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器旧响应不覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。逐提供方元数据失败内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService``ModelDirectory` 及其状态形状、坑位注入面类型。
## Model Experience
间接影响,经两个入口共同提交的 `session.selectModel` RPChost 在下一次提示词组装边界快照所选提供方/模型对,因此后续请求按所选目标路由(并盖入提示词变量),运行中的步骤保持其已组装目标——目录、两个菜单及全部选择交互都留在 client 侧,永不进入 session log
间接影响,经两个入口共同提交的 `session.selectModel` RPCHost 在下一次提示词组装边界快照所选提供方模型/推理强度目标,因此后续请求采用所选路由和推理强度,而运行中的步骤保已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容
#### KV Cache effect
@@ -17,5 +17,5 @@
## Known Limitations and Deferred Work
- **无创建期选择**——两个入口都寻址既有会话的 agent没有 Draft 期模型选择折入会话创建的通道host `targetFor` 处的种子序注释记录了该层未来的落点)。
- **目录名仅供呈现**——选择与持久化使用提供方/模型 id目录查询失败的提供方以不可选失败行列出重新加载前保持原样。
- **坑位不显示 effort 档位**——figma 设计稿中的 `High` 文本尚无对应 wire 概念;触发器只渲染模型名
- **目录名仅供呈现**——选择与持久化使用提供方模型/推理强度 id目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行

View File

@@ -6,37 +6,38 @@
* the shared directory, and the effort levels. The trigger (313:14108's
* ToggleButton) shows both: model name + effort in the caption tone.
* Data and submission ride the SAME per-session ModelDirectory as the
* /model popup; effort is a client-local display echo until a wire carries
* a per-session override (see the directory's state contract).
* /model popup; exact-model reasoning metadata and the selected effort come
* from the Host rather than a client-owned vocabulary.
*/
import {
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
type KeyboardEvent, type FocusEvent,
} from 'react'
import clsx from 'clsx'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ModelEffort } from './directory.ts'
import type { ModelSelectInjected } from './slots.ts'
import css from './ModelSelect.module.css'
/** The displayable effort levels (deepseek wire vocabulary, capitalized for the UI). */
const EFFORT_LEVELS: readonly { id: ModelEffort; label: string }[] = [
{ id: 'high', label: 'High' },
{ id: 'max', label: 'Max' },
]
/** Which pane the dropdown shows: the two-row root or one drilled-in list. */
type Pane = 'root' | 'model' | 'effort'
/** One dynamic effort row; undefined means preserve the provider default. */
interface EffortChoice {
key: string
effort: string | undefined
label: string
description?: string
}
/**
* Render the composer model seat.
* @param props - owner share (locked) + injected face (shared directory store/verbs).
* @returns the trigger and, while open, the two-level menu.
*/
export function ModelSelect({ locked, directory, load, select, setEffort }: ModelSelectInjected & { locked: boolean }) {
export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
() => directory.getSnapshot(),
@@ -52,13 +53,39 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
group.models.map(model => ({
group,
model,
target: { provider: group.id, model: model.id } satisfies ModelTarget,
target: {
provider: group.id,
model: model.id,
...model.reasoning?.defaultEffort === undefined
? {}
: { reasoningEffort: model.reasoning.defaultEffort },
} satisfies ModelTarget,
}))), [state.groups])
const selectedIndex = state.current === null
? -1
: choices.findIndex(c => c.target.provider === state.current?.provider && c.target.model === state.current.model)
const currentChoice = choices[selectedIndex]
const reasoning = currentChoice?.model.reasoning
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
const effortLabel = reasoning === undefined
? undefined
: effectiveEffort === undefined
? 'Provider default'
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
? []
: [
...reasoning.defaultEffort === undefined
? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }]
: [],
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
key: `effort:${effort.id}`,
effort: effort.id,
label: effort.name,
...effort.description === undefined ? {} : { description: effort.description },
})),
], [reasoning])
const busy = state.status === 'selecting'
const effortLabel = EFFORT_LEVELS.find(l => l.id === state.effort)?.label ?? 'High'
// Mount-time load resolves the trigger label; every open refreshes.
useEffect(() => { load() }, [load])
@@ -122,7 +149,24 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
})
}
const chooseEffort = (effort: string | undefined): void => {
if (state.current === null) return
if (effectiveEffort === effort) {
close(true)
return
}
const target: ModelTarget = {
provider: state.current.provider,
model: state.current.model,
...effort === undefined ? {} : { reasoningEffort: effort },
}
void select(target).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
}
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
itemRefs.current = []
let itemIndex = 0
const itemRef = () => {
@@ -136,16 +180,16 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={`选择模型,当前 ${modelLabel}effort ${effortLabel}`}
aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
title={`${modelLabel} · ${effortLabel}`}
title={triggerLabel}
disabled={locked}
onClick={() => { open ? close() : show() }}
>
<span className={css.triggerLabel}>{modelLabel}</span>
<span className={css.triggerEffort}>{effortLabel}</span>
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
@@ -154,7 +198,7 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
id={`${id}-menu`}
className={css.menu}
role="menu"
aria-label="模型与 effort"
aria-label="模型与推理等级"
aria-busy={state.status === 'loading' || busy}
>
{pane === 'root' && (
@@ -164,11 +208,13 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
<span className={css.cellValue}>{modelLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => setPane('effort')}>
<span className={css.cellLabel}>Effort</span>
<span className={css.cellValue}>{effortLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
{reasoning !== undefined && (
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => setPane('effort')}>
<span className={css.cellLabel}>Effort</span>
<span className={css.cellValue}>{effortLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
)}
</>
)}
@@ -234,24 +280,40 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
</>
)}
{pane === 'effort' && EFFORT_LEVELS.map(level => (
<button
ref={itemRef()}
type="button"
role="menuitemradio"
aria-checked={state.effort === level.id}
className={clsx(css.option, state.effort === level.id && css.selected)}
key={level.id}
onClick={() => { setEffort(level.id); close(true) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{level.label}</span>
</span>
<span className={css.check}>
{state.effort === level.id ? <IconCheckOutline16 /> : null}
</span>
</button>
))}
{pane === 'effort' && (
<>
{state.error !== null && (
<div className={css.error}>
<span>{state.error}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
</div>
)}
{effortChoices.length === 0
? <div className={css.empty}></div>
: effortChoices.map(level => (
<button
ref={itemRef()}
type="button"
role="menuitemradio"
aria-checked={effectiveEffort === level.effort}
className={clsx(css.option, effectiveEffort === level.effort && css.selected)}
key={level.key}
disabled={busy}
onClick={() => { chooseEffort(level.effort) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{level.label}</span>
{level.description !== undefined && (
<span className={css.description}>{level.description}</span>
)}
</span>
<span className={css.check}>
{effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}
</span>
</button>
))}
</>
)}
</div>
)}
</div>

View File

@@ -11,19 +11,8 @@ import type {
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Thinking-effort display levels (the deepseek wire vocabulary). */
export type ModelEffort = 'high' | 'max'
/** Directory snapshot both entries render from. */
export interface ModelDirectoryState {
/**
* Displayed thinking-effort level. Client-local echo only for now: the
* design pairs model and effort as one two-level selection, but no wire
* carries a per-session effort override yet (the deepseek adapter's
* reasoningEffort is deployment config) — selecting it updates this
* display state and nothing else.
*/
effort: ModelEffort
/** Target the host reports for the next assembled step; null before the first load. */
current: ModelTarget | null
/** Successfully loaded provider groups (last good load). */
@@ -40,7 +29,7 @@ export interface ModelDirectoryState {
export class ModelDirectory {
/** The shared snapshot both entries render from (uSES-safe store). */
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
effort: 'high', current: null, groups: [], failures: [], status: 'idle', error: null,
current: null, groups: [], failures: [], status: 'idle', error: null,
})
/** Latest operation wins; an older response never overwrites a newer one. */
@@ -85,16 +74,21 @@ export class ModelDirectory {
}
/**
* Select the complete route (both entries submit through here). Success
* Select the complete provider/model/reasoning target (both entries submit through here). Success
* updates the shared current; failure surfaces on the store and throws so
* each entry's own retry surface engages.
* @param target - provider and provider-owned model id.
* @param target - provider, provider-owned model id, and optional adapter-owned effort.
*/
async select(target: ModelTarget): Promise<void> {
const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.error = null })
const { result } = await this.sessions.selectModel({
sessionId: this.sessionId, provider: target.provider, model: target.model,
sessionId: this.sessionId,
provider: target.provider,
model: target.model,
...target.reasoningEffort === undefined
? {}
: { reasoningEffort: target.reasoningEffort },
})
if (this.disposed || generation !== this.generation) {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
@@ -107,15 +101,6 @@ export class ModelDirectory {
this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null })
}
/**
* Set the displayed effort level (client-local; see the state field's contract).
* @param effort - the level to display.
*/
setEffort(effort: ModelEffort): void {
if (this.disposed) return
this.store.update((s) => { s.effort = effort })
}
/**
* Drop the previous Host generation's projection and repull it. Clearing
* first prevents an unconsumed process-local selection from being displayed

View File

@@ -20,7 +20,7 @@ import type { ModelSelectInjected } from './slots.ts'
import { ModelSelect } from './ModelSelect.tsx'
export { ModelDirectory } from './directory.ts'
export type { ModelDirectoryState, ModelEffort } from './directory.ts'
export type { ModelDirectoryState } from './directory.ts'
export { ModelService } from './service.ts'
export type { ModelSelectInjected } from './slots.ts'
@@ -61,7 +61,16 @@ function optionsOf(directory: SessionModels): SelectOption[] {
function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefined {
for (const group of state.groups) {
for (const model of group.models) {
if (rowId(group.id, model.id) === id) return { provider: group.id, model: model.id }
if (rowId(group.id, model.id) !== id) continue
const sameRoute = state.current?.provider === group.id && state.current.model === model.id
const reasoningEffort = sameRoute
? state.current?.reasoningEffort ?? model.reasoning?.defaultEffort
: model.reasoning?.defaultEffort
return {
provider: group.id,
model: model.id,
...reasoningEffort === undefined ? {} : { reasoningEffort },
}
}
}
return undefined
@@ -114,7 +123,6 @@ export function apply(ctx: ClientContext): void {
directory: directory.store,
load: () => { directory.load().catch(() => { /* surfaced on the store */ }) },
select: (target: ModelTarget) => directory.select(target).then(() => true, () => false),
setEffort: (effort) => { directory.setEffort(effort) },
}
},
}, ModelSelect), 'ui-model: composer model seat registration')

View File

@@ -6,7 +6,7 @@
*/
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelDirectoryState, ModelEffort } from './directory.ts'
import type { ModelDirectoryState } from './directory.ts'
/** Injected business face of the composer model seat. */
export interface ModelSelectInjected {
@@ -15,15 +15,9 @@ export interface ModelSelectInjected {
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */
load(): void
/**
* Select a complete provider/model target through the shared route.
* @param target - target picked from one provider group.
* Select a complete provider/model/reasoning target through the shared route.
* @param target - model target and optional adapter-owned effort.
* @returns whether the host accepted the selection.
*/
select(target: ModelTarget): Promise<boolean>
/**
* Set the displayed thinking-effort level (client-local echo; see the
* directory state contract).
* @param effort - the level to display.
*/
setEffort(effort: ModelEffort): void
}

View File

@@ -23,8 +23,30 @@ const GROUPS = [{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
defaultEffort: 'high',
},
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
defaultEffort: 'high',
},
},
],
}]
@@ -38,9 +60,15 @@ async function bench() {
calls.models += 1
return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } })
},
selectModel: (payload: { provider: string; model: string }) => {
selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => {
calls.select += 1
current = { provider: payload.provider, model: payload.model }
current = {
provider: payload.provider,
model: payload.model,
...payload.reasoningEffort === undefined
? {}
: { reasoningEffort: payload.reasoningEffort },
}
return Promise.resolve({ result: { ok: true as const, value: { selected: current } } })
},
} } })
@@ -102,9 +130,21 @@ describe('ui-model dual entry', () => {
b.mint('s1')
const seatFace = b.seat().inject!(sid('s1'))
// Switch through the SEAT entry.
expect(await seatFace.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).toBe(true)
expect(b.hostCurrent()).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(await seatFace.select({
provider: 'deepseek',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})).toBe(true)
expect(b.hostCurrent()).toEqual({
provider: 'deepseek',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})
expect(seatFace.directory.getSnapshot().current).toEqual({
provider: 'deepseek',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})
// The POPUP's next options pass reflects it without a seat-side reload.
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true })
@@ -117,7 +157,11 @@ describe('ui-model dual entry', () => {
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')!
await b.contribution().ui.onSelect(pro, projection('s1'))
expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(seatFace.directory.getSnapshot().current).toEqual({
provider: 'deepseek',
model: 'deepseek-v4-pro',
reasoningEffort: 'high',
})
})
it('both entries share one directory instance per session, isolated across sessions', async () => {

View File

@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelDirectoryState } from '../src/client/directory.ts'
import { ModelSelect } from '../src/client/ModelSelect.tsx'
const reasoning = {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max', description: 'Largest budget' },
],
defaultEffort: 'high',
}
function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState {
return {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }],
}],
failures: [],
status: 'ready',
error: null,
...overrides,
}
}
afterEach(cleanup)
describe('ModelSelect reasoning effort', () => {
it('renders adapter metadata and submits the effort as part of the session target', async () => {
const directory = createSnapshotStore(state())
const select = vi.fn(async (target: ModelTarget) => {
directory.update((snapshot) => { snapshot.current = target })
return true
})
render(<ModelSelect
locked={false}
directory={directory}
load={vi.fn()}
select={select}
/>)
const trigger = screen.getByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash推理等级 High',
})
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Off', 'High', 'MaxLargest budget'])
fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ }))
await waitFor(() => {
expect(select).toHaveBeenCalledWith({
provider: 'deepseek',
model: 'deepseek-v4-flash',
reasoningEffort: 'max',
})
expect(trigger.getAttribute('aria-label')).toBe('选择模型,当前 DeepSeek-V4-Flash推理等级 Max')
})
})
it('offers provider default only when the adapter does not configure a model default', () => {
const directory = createSnapshotStore(state({
groups: [{
id: 'provider',
name: 'Provider',
models: [{
id: 'model',
name: 'Model',
reasoning: { efforts: [{ id: 'standard', name: 'Standard' }] },
}],
}],
current: { provider: 'provider', model: 'model' },
}))
render(<ModelSelect
locked={false}
directory={directory}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
/>)
fireEvent.click(screen.getByRole('button', {
name: '选择模型,当前 Model推理等级 Provider default',
}))
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Provider default', 'Standard'])
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: b5b35f0ec0e5194eae98f74af938f9cff36bc6f1
README.zh.md: 9cb91a1da151389e713f5e995b52675db562ab15
README.md: 296ce8dd999aa251f87a9dbadb2ef821fe58e173
README.zh.md: b6191ac4b096af191be17a766f3910c02db6c3ed

View File

@@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Session model routing is a session-domain contract. `session.history` returns the selected `modelTarget`, `session.models` returns that target with provider-grouped advisory model metadata and provider-local lookup failures, and `session.selectModel` replaces the target selected for the next prompt-assembly boundary. Catalog membership is not validation: a registered provider may accept an unlisted model, while an unregistered provider returns `model-unavailable`.
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.

View File

@@ -12,7 +12,7 @@
mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
会话模型路由属于会话领域契约。`session.history` 返回选中的 `modelTarget``session.models` 返回该目标,以及按提供方分组的仅供参考模型元数据和逐提供方查询失败记录`session.selectModel` 替换将在下一提示词组装边界使用的目标。目录成员关系不构成校验:已注册提供方可以接受未列出的模型,而未注册提供方会返回 `model-unavailable`
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方模型推理reasoning目标以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。

View File

@@ -8,7 +8,10 @@ import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus,
} from '@deepseek-ai/dsh-agent'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
@@ -21,7 +24,7 @@ import {
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelTarget,
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
@@ -359,7 +362,7 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
type WebLlmTargetRef = AgentLlmTargetRef & { current: ModelTarget }
type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
const targets = new WeakMap<Agent, WebLlmTargetRef>()
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
@@ -383,7 +386,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const target: WebLlmTargetRef = {
current: logged === undefined
? { provider: defaults.provider, model: defaults.model }
: { provider: logged.provider, model: logged.model },
: {
provider: logged.provider,
model: logged.model,
...logged.reasoningEffort === undefined
? {}
: { reasoningEffort: logged.reasoningEffort },
},
assembled: undefined,
}
installAgentLlmTarget(agent.ctx, target)
@@ -710,15 +719,50 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const current = targetFor(found.agent).current
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
try {
const models = await ctx.llm.listModels(provider.id)
const group: ModelProviderGroup = {
id: provider.id,
name: provider.name,
models: models.map(model => ({
const advertised = await ctx.llm.listModels(provider.id)
const models = [...advertised]
if (
provider.id === current.provider
&& !models.some(model => model.id === current.model)
) {
models.push({
provider: provider.id,
id: current.model,
name: current.model,
})
}
const entries = await Promise.all(models.map(async (model) => {
const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
? undefined
: {
efforts: resolved.reasoning.efforts.map(effort => ({
id: effort.id,
name: effort.name,
...effort.description === undefined
? {}
: { description: effort.description },
})),
...resolved.reasoning.defaultEffort === undefined
? {}
: { defaultEffort: resolved.reasoning.defaultEffort },
}
return {
id: model.id,
name: model.name,
...model.description === undefined ? {} : { description: model.description },
})),
...provider.id === current.provider
&& model.id === current.model
&& !advertised.some(candidate => candidate.id === current.model)
? { unlisted: true as const }
: {},
...reasoning === undefined ? {} : { reasoning },
}
}))
const group: ModelProviderGroup = {
id: provider.id,
name: provider.name,
models: entries,
}
return { kind: 'group' as const, group }
} catch (error: unknown) {
@@ -732,17 +776,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}))
const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
const currentGroup = groups.find(group => group.id === current.provider)
if (
currentGroup !== undefined
&& !currentGroup.models.some(model => model.id === current.model)
) {
currentGroup.models.push({
id: current.model,
name: current.model,
unlisted: true,
})
}
return ok(request, {
current: { ...current },
groups: groups.filter(group => group.models.length > 0),
@@ -751,19 +784,33 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
async selectModel(request) {
const { sessionId, provider, model } = request.payload
const { sessionId, provider, model, reasoningEffort } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
if (!ctx.llm.listProviders().some(entry => entry.id === provider)) {
try {
const resolved = await ctx.llm.resolveCallConfig({
provider,
model,
...reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
})
const selected: AgentLlmTarget = {
provider: resolved.provider,
model: resolved.model,
...resolved.reasoningEffort === undefined
? {}
: { reasoningEffort: resolved.reasoningEffort },
}
targetFor(found.agent).current = selected
return ok(request, { selected: { ...selected } })
} catch (error: unknown) {
return err(request, {
code: 'model-unavailable',
message: `provider "${provider}" is not registered`,
message: error instanceof Error ? error.message : String(error),
details: { provider, model },
})
}
const selected: ModelTarget = { provider, model }
targetFor(found.agent).current = selected
return ok(request, { selected: { ...selected } })
},
async prompt(request) {

View File

@@ -26,8 +26,8 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget,
SessionModels, SessionsApi, SessionSummary,
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'

View File

@@ -10,8 +10,8 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget,
SessionSummary,
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
@@ -83,14 +83,29 @@ export const sessionHistoryRequestSchema = z.object({
export const modelTargetSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<ModelTarget>>
/** One adapter-owned reasoning effort. */
export const modelReasoningEffortSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
}) satisfies z.ZodType<Wire<ModelReasoningEffort>>
/** Exact-model reasoning metadata. */
export const modelReasoningSchema = z.object({
efforts: z.array(modelReasoningEffortSchema).min(1),
defaultEffort: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<ModelReasoning>>
/** One advisory model entry inside a provider group. */
export const modelCatalogModelSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
unlisted: z.literal(true).optional(),
reasoning: modelReasoningSchema.optional(),
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
/** One successfully loaded provider group. */
@@ -154,6 +169,7 @@ export const sessionSelectModelRequestSchema = z.object({
sessionId: sessionIdSchema,
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.selectModel'>>>
/** session.selectModel response value. */

View File

@@ -32,12 +32,32 @@ export interface HistoryEntry {
view?: ToolEventView
}
/** Complete provider/model route selected for one session. */
/** Complete model target selected for one session. */
export interface ModelTarget {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
/** Adapter-owned reasoning effort; absence preserves adapter/provider default behavior. */
reasoningEffort?: string
}
/** One adapter-owned reasoning effort displayed for an exact model route. */
export interface ModelReasoningEffort {
/** Opaque value submitted back to the owning adapter. */
id: string
/** Adapter-supplied display name. */
name: string
/** Optional adapter-supplied description. */
description?: string
}
/** Selectable reasoning metadata for one exact model route. */
export interface ModelReasoning {
/** Efforts in adapter-preferred display order. */
efforts: ModelReasoningEffort[]
/** Adapter-configured default; absence preserves the provider default. */
defaultEffort?: string
}
/** One model displayed inside its provider group. */
@@ -50,6 +70,8 @@ export interface ModelCatalogModel {
description?: string
/** The current model was inserted because the advisory catalog omitted it. */
unlisted?: true
/** Exact-route reasoning metadata when the adapter exposes it. */
reasoning?: ModelReasoning
}
/** One provider and the models it advertised successfully. */
@@ -139,10 +161,16 @@ export interface SessionsApi {
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
/**
* Selects the complete route for this session. The registered provider is
* validated, while model catalog membership remains advisory.
* Selects the complete target for this session. Exact model metadata
* validates an optional reasoning effort, while catalog membership remains
* advisory.
*/
selectModel(request: RpcRequest<{ sessionId: SessionId; provider: string; model: string }>):
selectModel(request: RpcRequest<{
sessionId: SessionId
provider: string
model: string
reasoningEffort?: string
}>):
Promise<RpcResponse<{ selected: ModelTarget }>>
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */

View File

@@ -8,9 +8,10 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmProviderInfo, StreamChunk,
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -29,6 +30,8 @@ class CatalogAdapter extends LlmAdapter {
constructor(
private readonly name: string,
private readonly models: readonly LlmModelInfo[] | Error,
private readonly reasoning?: LlmModelReasoningInfo,
private readonly exactError?: Error,
) {
super()
}
@@ -43,12 +46,35 @@ class CatalogAdapter extends LlmAdapter {
: Promise.resolve(this.models)
}
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
if (this.exactError !== undefined) return Promise.reject(this.exactError)
return Promise.resolve({
provider,
id: model,
name: model,
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
})
}
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
// Catalog tests never enter provider streaming.
}
}
async function harness(logged?: { provider: string; model: string }): Promise<{
const REASONING: LlmModelReasoningInfo = {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId('high'),
}
async function harness(logged?: {
provider: string
model: string
reasoningEffort?: ReasoningEffortId
}): Promise<{
ctx: Context
agent: Agent
sessionId: SessionId
@@ -62,8 +88,11 @@ async function harness(logged?: { provider: string; model: string }): Promise<{
ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
{ provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
]))
], REASONING))
ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
{ provider: 'metadata-broken', id: 'listed', name: 'Listed' },
], undefined, new Error('reasoning metadata offline')))
ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
{ provider: 'duplicate', id: 'same', name: 'Same' },
@@ -90,22 +119,41 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
describe('Web session model selection', () => {
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
const { ctx, sessionId } = await harness({ provider: 'deepseek', model: 'private-preview' })
const { ctx, sessionId } = await harness({
provider: 'deepseek',
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({ provider: 'deepseek', model: 'private-preview' })
expect(catalog.current).toEqual({
provider: 'deepseek',
model: 'private-preview',
reasoningEffort: 'max',
})
expect(catalog.groups).toEqual([{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
{ id: 'private-preview', name: 'private-preview', unlisted: true },
{ id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
{
id: 'deepseek-reasoner',
name: 'DeepSeek Reasoner',
description: 'Reasoning model',
reasoning: REASONING,
},
{
id: 'private-preview',
name: 'private-preview',
unlisted: true,
reasoning: REASONING,
},
],
}])
expect(catalog.failures).toEqual([
{ id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
{ id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
{
id: 'duplicate',
name: 'Duplicate Provider',
@@ -130,8 +178,13 @@ describe('Web session model selection', () => {
sessionId,
provider: 'deepseek',
model: 'private-preview',
reasoningEffort: 'max',
})))
expect(selected.selected).toEqual({ provider: 'deepseek', model: 'private-preview' })
expect(selected.selected).toEqual({
provider: 'deepseek',
model: 'private-preview',
reasoningEffort: 'max',
})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
@@ -140,7 +193,25 @@ describe('Web session model selection', () => {
.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
)).resolves.toMatchObject({
provider: 'deepseek',
model: 'private-preview',
reasoningEffort: 'max',
})
const unsupported = await api.sessions.selectModel(request({
sessionId,
provider: 'deepseek',
model: 'private-preview',
reasoningEffort: 'medium',
}))
expect(unsupported.result).toMatchObject({
ok: false,
error: {
code: 'model-unavailable',
message: 'provider "deepseek" model "private-preview" does not support reasoning effort "medium"',
},
})
const rejected = await api.sessions.selectModel(request({
sessionId,
@@ -151,12 +222,12 @@ describe('Web session model selection', () => {
ok: false,
error: {
code: 'model-unavailable',
message: 'provider "missing" is not registered',
message: 'no adapter registered for provider "missing"',
details: { provider: 'missing', model: 'model' },
},
})
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek', model: 'private-preview' })
.toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
await ctx.fiber.dispose()
})
})

View File

@@ -54,7 +54,15 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
rpcId: request.rpcId,
result: {
ok: true,
value: { selected: { provider: request.payload.provider, model: request.payload.model } },
value: {
selected: {
provider: request.payload.provider,
model: request.payload.model,
...request.payload.reasoningEffort === undefined
? {}
: { reasoningEffort: request.payload.reasoningEffort },
},
},
},
}
},
@@ -160,11 +168,22 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const c = client()
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.sessions.selectModel({
const selected = await c.sessions.selectModel({
sessionId: 's' as never,
provider: 'deepseek',
model: 'deepseek-v4-flash',
})).result.ok).toBe(true)
reasoningEffort: 'max',
})
expect(selected.result).toMatchObject({
ok: true,
value: {
selected: {
provider: 'deepseek',
model: 'deepseek-v4-flash',
reasoningEffort: 'max',
},
},
})
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.host.describe({})).result.ok).toBe(true)

View File

@@ -141,7 +141,7 @@ describe('sessions domain schemas', () => {
}).hasMore).toBe(false)
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
@@ -150,6 +150,13 @@ describe('sessions domain schemas', () => {
name: 'DeepSeek V4 Flash',
description: 'fast',
unlisted: true,
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'max', name: 'Max', description: 'Largest budget' },
],
defaultEffort: 'off',
},
}],
}],
failures: [{ id: 'broken', name: 'Broken', message: 'offline' }],
@@ -158,15 +165,31 @@ describe('sessions domain schemas', () => {
sessionId: 's1',
provider: 'deepseek',
model: 'deepseek-v4-pro',
}).model).toBe('deepseek-v4-pro')
reasoningEffort: 'max',
}).reasoningEffort).toBe('max')
expect(sessionSelectModelValueSchema.parse({
selected: { provider: 'deepseek', model: 'deepseek-v4-pro' },
}).selected.model).toBe('deepseek-v4-pro')
selected: { provider: 'deepseek', model: 'deepseek-v4-pro', reasoningEffort: 'max' },
}).selected.reasoningEffort).toBe('max')
expect(() => sessionSelectModelRequestSchema.parse({
sessionId: 's1',
provider: '',
model: 'm',
})).toThrow()
expect(() => sessionSelectModelRequestSchema.parse({
sessionId: 's1',
provider: 'deepseek',
model: 'm',
reasoningEffort: '',
})).toThrow()
expect(() => sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'm' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'm', name: 'M', reasoning: { efforts: [] } }],
}],
failures: [],
})).toThrow()
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
expect(prompt.mode).toBe('queue')
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()