**A hand-declared route must not offer a reasoning effort.** The earlier commit read the create card's missing control as drift and added one. It is the other way round: such a model has no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under the route — so `resolveModel` throws UNSUPPORTED_REASONING_EFFORT for every model on it and the whole provider drops out of the picker. Verified against the adapter, not inferred. The create card no longer offers it and the editor withholds it on the directory's `declared` bit, which is the real bug: that control has always been wrong for these routes. **A blocked composer locked the way out of the block.** Reusing the no-workspace inert posture disabled the model seat along with everything else, so the bar asked for a model while preventing the one control that picks one. A block now rides its own `blocked` owner prop: the textarea, send, commands, plan seat, and access chip all lock, and the model seat alone stays live. **A Provider ID could derive an illegal credential reference.** The card accepted a digit-leading id, whose derived `123_API_KEY` then failed at the credential seam with a raw regular expression the user cannot act on. The id must now start with a letter, and a test pins the relation between the two rules rather than the regex.
5.8 KiB
Agent Note: 默认模型跟随选择器
Status: implemented
English | 中文
问题
新会话的起始路由被冻结在网关的组合条目里(web-app bundle patch 中的 api-gateway 行)。在一段对话里切换模型只影响这段对话:下一个会话又回到出厂默认,而要改这个默认值,唯一的办法是手工编辑一条 cordis.yml 行并重启。组合层与每会话选择之间没有用户设置这一层。
决定
ApiProxyService 把自己的 {provider, model, reasoningEffort?} 切片注册为 api-gateway 设置段:组合条目是 base 层,settings.yaml 把用户的选择叠加其上。workspaceRoot 留在段外——它是启动器事实,不是偏好。reasoningEffort 则是镜像的一例:它在段里、但不在插件配置里,因为 seam 是按字段把用户层合并到组合条目之上的,缺席的键覆盖不了存在的键。组合层设的推理等级因此会在此后每一次切到不支持推理的模型时继续存活——正是整段 replace 想要杜绝的那种滞留。何况推理等级本就是按模型的事实,它的部署级默认值属于适配器 profile,那里是按模型解析的。
session.selectModel 把被接受的切换记录为新的默认值。没有另一个单独的手势:在输入框切模型就是选定默认值的方式。写入用 replace 而非 update——切到一个不支持推理的模型必须清掉已存的等级,而合并补丁会把它滞留下来,让下一个会话在它上面失败。存储失败只记日志,不撤销这次切换(它对自己所在的会话已经生效);没有设置提供方的部署保留组合条目,切换只停留在进程内。
ApiProxyDefaults 改为携带 defaultTarget() 与 persistDefaultTarget() 两个闭包,而不是扁平的 provider/model 字段,这样 createApiProxy 不需要知道设置这条缝的存在。
targetFor 在每一次读取时解析各级,而不是只在创建时种一次 ref:本进程内的显式选择,其次是该会话自己最新记录的 request/header,最后才是活的默认值。两个方向都依赖这次重新读取。已经跑过一轮的会话此后永远从自己的日志推导,改默认值不会重定向它;而仍然空白的会话会用上它创建之后才保存的默认值——这一点很关键,因为新建会话是复用空白会话而不是再开一个,创建时种下的值恰好会在这个功能存在的意义所在的流程里显示已被取代的模型。
存下来的路由不做注册表校验。默认值指向一条模型页已经删除的路由时,它照样作为 current 送到 session.models,匹配不到任何已公布的分组——而这正是让输入框选择器已有的回退提示重新选择、而不是显示一个部署根本够不着的模型的原因。
影响
ApiProxyDefaults 形状变了,约 40 处测试构造点随之更新。host.describe 现在报告的是活的默认值而非捕获的快照,这本就是它一直想表达的含义。用户一旦切换模型,settings.yaml 就会多出一个 api-gateway: 段;api-gateway 这个 namespace 刻意没有加进网关的暴露名单,因此设置页既不读也不写它——模型选择器就是它的编辑器。
后续:让发不出消息的会话禁止输入
默认值指向一条模型页已删除的路由时,编辑器显示「选择模型」,输入框却仍接受消息,然后这一轮在适配器内部失败。两处改动关掉这个口子。
宿主拒绝。session.prompt 检查是否有适配器服务该会话的路由,在开启轮次之前就以 model-unavailable 应答。这是执行边界:客户端禁用编辑器只是提示性设计,这个方法始终可被调用。
编辑器变惰性。session.models 报告 routable,ui-model 经新的 ctx.conversation.blocks 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder——唯独模型 seat 被 block 刻意保留可用,因为用户正是靠选模型来解除它。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。
闸门是 routable,不是「当前目标匹配不到任何已公布分组」。目录成员关系按设计是咨询性的:一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用,在那里阻断会破坏一种受支持的配置(对一条活着的路由收窄 models 列表)。routable 在客户端还是三值的——首次加载之前或加载失败之后的 null 绝不阻断,因此慢的或够不着的宿主锁不死一个本来能用的编辑器。
考虑过的替代方案
- 存下来的路由未注册时回落到组合条目。 否决:那样输入框会显示出厂的 DeepSeek 模型而不是提示选择,既是静默切到用户没选的提供方,也与要求的行为正好相反。
- 校验并清空失效的默认值。 否决:目录成员关系按设计是咨询性的(
buildModelCatalog有注释说明),适配器可以服务一个自己目录已不再公布的模型;自动修复会破坏这个刻意保留的情形。 - 用
settings.update合并补丁。 否决:它清不掉reasoningEffort,于是从推理模型切到普通模型会留下一个等级,让下一个会话在它上面失败。 - 只在空白会话里持久化。 否决:最有信息量的切换恰恰是对话到一半发现模型不行时做的那一次,而它永远存不下来。
- 单独做一个「设为默认」的入口。 目前否决:同类产品都从切换本身推断的事情,它却要多一个手势。代价是在老会话里的临时切换也会移动默认值。