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:
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user