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

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)
},