feat(ui): make a session that cannot send refuse to accept one

A default naming a route the Models page has since removed left the
composer saying 选择模型 while the input still accepted a message, which
then failed inside the adapter mid-turn.

`session.prompt` now refuses with `model-unavailable` before opening a
turn. That is the enforcement boundary: the method stays callable no
matter what a client disables. `session.models` reports the same fact as
`routable`, and ui-model pushes a block through the new
`ctx.conversation.blocks` registry so the bar renders the disabled
textarea it already renders without a workspace, carrying the blocker's
own reason. The push direction is forced — ui-model already depends on
ui-conversation, so ui-conversation cannot read it back.

The gate is `routable`, not "matches no advertised group": catalog
membership is advisory, so a route serving a model it stopped advertising
is missing from the groups yet perfectly usable, and `null` before the
first load never blocks so a slow Host cannot lock a working composer.

The scaffold gains a route-only adapter for fixture-less keyless
scenarios. Registering zero providers is a test artifact — every product
composition mounts one — and the goldens that froze the seat's fallback
label now show the model those scenarios actually route to.
This commit is contained in:
Yichen Jiang
2026-08-07 15:26:42 +08:00
parent 72618f29b5
commit bb43ff4f37
58 changed files with 859 additions and 163 deletions
+50 -6
View File
@@ -74,6 +74,14 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/**
* The settings namespace carrying the user's default route. Named for the
* gateway rather than for the package, because this key is what a person reads
* and writes in `settings.yaml`; the row id in a composition happens to match
* but does not determine it.
*/
export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway')
/** Non-model settings namespaces intentionally served to the Web client. */
const WEB_SETTINGS_NAMESPACES = ['permission'] as const
@@ -337,9 +345,11 @@ export interface ApiProxyDefaults {
*/
defaultTarget: () => AgentLlmTarget
/**
* Record a selection as the new default. Absent when the deployment stores
* no user settings, in which case a switch stays process-local. A rejection
* is reported and swallowed: the switch already applies to its own session,
* Record a selection as the new default. Either absent, or a closure that
* may itself decline — the gateway plugin always passes one, and it no-ops
* when the deployment mounts no settings provider or when the write races
* service teardown. A switch then stays process-local. A rejection is
* reported and swallowed: the switch already applies to its own session,
* and undoing it because storage failed would be the worse outcome.
*/
persistDefaultTarget?: (target: AgentLlmTarget) => Promise<void>
@@ -1330,6 +1340,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}
/**
* Whether an adapter currently serves this route, and therefore whether a
* session pointed at it can start a turn. Catalog membership cannot answer
* it: an adapter may serve a model its own catalog stopped advertising, so
* a route missing from the groups is not the same as one nothing serves.
* A composition with no llm registry at all cannot judge and says yes —
* the dispatch it would have refused fails on its own terms.
*/
function routeServed(provider: string): boolean {
const llm = ctx.get('llm')
return llm === undefined || llm.listProviders().some(entry => entry.id === provider)
}
/** Missing-service report shared by the settings domain (skills-domain stance). */
function settingsAbsent(): RpcError {
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
@@ -1700,7 +1723,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if ('error' in found) return err(request, found.error)
const current = targetFor(found.agent).current
const { groups, failures } = await buildModelCatalog(ctx)
return ok(request, { current: { ...current }, groups, failures })
const routable = routeServed(current.provider)
return ok(request, { current: { ...current }, routable, groups, failures })
},
async selectModel(request) {
@@ -1868,6 +1892,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const agent = found.agent
// A route no adapter serves cannot start a turn, and letting it try
// spends the whole pre-step path to fail inside the adapter with a
// message about registration. Refusing here names the model the
// session is pointed at while the draft is still in the composer.
// This is the enforcement boundary: a client that disables its input
// is an affordance, and this method stays callable regardless.
const target = targetFor(agent).current
if (!routeServed(target.provider)) {
return err(request, {
code: 'model-unavailable',
message: `no adapter serves provider "${target.provider}"; select a model for this session`,
details: { provider: target.provider, model: target.model },
})
}
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
@@ -2758,8 +2796,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ type: 'host/settings-changed', ns: name }))
// A provider's own settings carry its model catalog and endpoint,
// so a change there invalidates the model list even when the route
// set is untouched — `llm/adapters-updated` alone misses it.
if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' }))
// set is untouched — `llm/adapters-updated` alone misses it. The
// gateway's own section is the other such source: it names the
// route every session with no logged one resolves to, so an
// externally edited default (another tab, a hand-edited
// settings.yaml) has to reach an open selector too.
if (modelProviderNamespaces().has(name) || name === String(API_GATEWAY_SETTINGS_NAMESPACE)) {
queue.push(frame({ type: 'host/models-changed' }))
}
}),
ctx.on('credentials/updated', (ref) => {
queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
@@ -225,6 +225,7 @@ export const sessionModelsRequestSchema = z.object({
/** session.models response value. */
export const sessionModelsValueSchema = z.object({
current: modelTargetSchema,
routable: z.boolean(),
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
@@ -117,6 +117,15 @@ export interface ModelCatalogFailure {
export interface SessionModels {
/** Target selected for the session's next assembled step. */
current: ModelTarget
/**
* Whether an adapter currently serves `current.provider`, and therefore
* whether this session can start a turn at all. Deliberately NOT derivable
* from `groups`: catalog membership is advisory, so a route serving a model
* it stopped advertising is absent from the groups yet perfectly usable,
* while a route whose adapter is gone can serve nothing. A surface that
* blocks input must read this rather than the groups.
*/
routable: boolean
/** Successfully loaded provider groups. */
groups: ModelProviderGroup[]
/** Provider-local failures; successful groups remain usable. */
+36 -40
View File
@@ -19,16 +19,16 @@ import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { AgentLlmTarget } from '@deepseek-ai/dsh-agent'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
import type { ApiProxy } from './api/index.ts'
import { createApiProxy } from './api-proxy.ts'
import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts'
export type * from './api/index.ts'
export { RpcId } from './api/rpc.ts'
export { toFetchHandler } from './fetch/handler.ts'
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
export type { IApiClient } from './fetch/client.ts'
export { createApiProxy } from './api-proxy.ts'
export { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
declare module 'cordis' {
@@ -39,17 +39,9 @@ declare module 'cordis' {
}
/**
* The settings namespace carrying the user's default route. Named for the
* gateway rather than for the package, because this key is what a person reads
* and writes in `settings.yaml`; the row id in a composition happens to match
* but does not determine it.
*/
export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway')
/**
* The user-settable slice of the gateway config: the route a session starts
* from when its own log names none. `workspaceRoot` is deliberately not part
* of it — that is a launcher fact, not a preference.
* The `api-gateway` settings section: the route a session starts from when its
* own log names none. `workspaceRoot` is deliberately not part of it — that is
* a launcher fact, not a preference.
*/
export interface DefaultRouteSettings {
/** Default provider route for created agents. */
@@ -60,29 +52,36 @@ export interface DefaultRouteSettings {
reasoningEffort?: string
}
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
export interface Config extends DefaultRouteSettings {
/**
* Gateway plugin config: host-level agent routing and Workspace creation root.
*
* `reasoningEffort` is deliberately absent, so the section carries one field
* the composition cannot. The seam resolves a section by MERGING the user
* layer over the composition entry per field, and an absent key cannot
* override a present one — so a composition-set effort would survive every
* later switch to a model that has none, and strand it for the next session
* to fail on. Effort is a per-model fact anyway: a deployment default belongs
* on the adapter profile (`llm-pi-ai`'s `reasoning`, `llm-deepseek`'s own),
* which resolves per model rather than per gateway.
*/
export interface Config {
/** Default provider route for created agents. */
provider: string
/** Default model id. */
model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
}
/** The config fields the settings section carries; the rest stay launcher-owned. */
const DEFAULT_ROUTE_FIELDS = ['provider', 'model', 'reasoningEffort'] as const
/**
* The settings section's schema, picked out of the plugin config rather than
* restated. The config stays a plain literal because the configuration-catalog
* generator reads it statically; picking from it is what keeps the section a
* subset of it as both evolve.
* @param config - the plugin config schema to pick from.
* @returns the section schema over {@link DEFAULT_ROUTE_FIELDS}.
* Schema of the `api-gateway` section, exported because it IS that section's
* contract — the shape anything reading or writing `settings.yaml` addresses.
*/
function defaultRouteSchema(config: z<Config>): z<DefaultRouteSettings> {
const fields = Object.fromEntries(
DEFAULT_ROUTE_FIELDS.map(field => [field, config.dict?.[field]]),
)
return z.object(fields) as z<DefaultRouteSettings>
}
export const DEFAULT_ROUTE_SCHEMA: z<DefaultRouteSettings> = z.object({
provider: z.string().required(),
model: z.string().required(),
reasoningEffort: z.string(),
})
/** Project the stored/composed section onto the agent-facing target shape. */
function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget {
@@ -109,7 +108,6 @@ export class ApiProxyService extends Service implements ApiProxy {
static Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
reasoningEffort: z.string(),
workspaceRoot: z.string(),
})
@@ -132,13 +130,9 @@ export class ApiProxyService extends Service implements ApiProxy {
// The composition entry is the shipped default; the settings section
// layers the user's own choice over it, and a deployment without a
// settings provider simply keeps the entry.
const entry: DefaultRouteSettings = {
provider: config.provider,
model: config.model,
...config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort },
}
const entry: DefaultRouteSettings = { provider: config.provider, model: config.model }
let route: () => DefaultRouteSettings = () => entry
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, defaultRouteSchema(ApiProxyService.Config), entry, {
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
setSource: (current) => {
route = current
},
@@ -150,8 +144,10 @@ export class ApiProxyService extends Service implements ApiProxy {
defaultTarget: () => routeTarget(route()),
// Wholesale, never a merge: switching to a model with no reasoning
// effort must clear a stored one, and a merged patch would strand it
// for the next session to fail on. The section holds no secrets, so
// there is nothing a replace can collaterally drop.
// for the next session to fail on. This clears it because the entry
// below the user layer carries no effort to re-inherit — the reason
// `Config` deliberately has no such field. The section holds no
// secrets, so there is nothing a replace can collaterally drop.
persistDefaultTarget: async (target) => {
await ctx.get('settings')?.replace(API_GATEWAY_SETTINGS_NAMESPACE, target)
},