# ADR-0017: 运行时引擎配置 -- 声明式 config store + 原子 ConfigSnapshot + provider 热换 + 密钥 encrypt-at-rest (settings UI 真生效) - Status: **Accepted + 后端实现 (PM 2026-06-06 拍 scope "八大模块尽量都接" + "授权开工"; 后端落地 + 测试绿)** -- 本文把 settings UI 从 mock 接到真后端的运行时配置架构. 后端已实现 (engineconfigstore + engineconfig + 4 端点 + cmd/common 装配, 全测过); settings.tsx 接真 + dispatch snapshot 默认下沉 = follow-up (见 §7). - 关联: ADR-0015 (平台前端底座, settings.tsx 是其 §九 八大模块的承载页) · ADR-0002 (REST 业务 / gRPC 观测 bifurcation, 配置端点走 REST 契约) · ADR-0003 (SessionStore 多副本边界, 本 ADR 的 config store 镜像其 InMemory+Postgres 双实现) · ADR-0012 (billcost prompt 进化, prompt preset 是其落点) · ADR-0008 (quote-dispatch 协议, 模型路由/审计的 per-request form 已存在). - 不在本 ADR: 多租户 RBAC 强制 (单租户现实, 见 §1.5) · 对账引擎 (ADR-0016 待) · flow 解释器 (节点库无消费者). --- ## 1. 背景 / Context ### 1.1 问题: settings UI 全 mock, 引擎配置启动时焊死 ADR-0015 搭出 `frontend/src/pages/settings.tsx` (P2 §九 八大模块的承载页), 但页内数据**全是示意** (注释自陈 "无 settings 后端"). 后端 grep 完**零配置端点**: 引擎的 provider / model / key / 审计开关全由**进程启动时一次性读 env** 焊进引擎 (`cmd/common/main.go`: `FLYTO_LLM_PROVIDER` 选 provider -> `engine.New(&engine.Config{Provider: provider})` 按值焊死; `ANTHROPIC_API_KEY` / `QUOTE_SUB_MODEL` / `QUOTE_AUDIT_ENABLED` 等). 启动后**没有任何运行时改配置的通路**. PM 原话: "真doc+真设置, 不然我咋用". 即 settings UI 要**真能配 + 真生效** -- 在浏览器改 provider key / 模型路由, 引擎下一个请求就用新值, 而不是改 compose `.env` + 重启容器. ### 1.2 诚实重述 scope: 多数是 "env/form 默认值挪进 store", 不是造庞大配置系统 逆向看清楚 "真设置" 到底要造什么, 避免 scope 失控: - **dispatch 路径早就吃 per-request 配置**: `sub_provider` / `main_provider` / `sub_model` / `main_model` / `audit_provider` / `audit_model` / `vision_provider` / `audit_enabled` 全是 multipart form 字段 (`quotedispatch_handler.go:2138-2245`), 每个请求可覆盖. 今天这些字段的**默认值**来自 env (`QUOTE_SUB_MODEL` 等). - 所以 settings 对 dispatch 而言**只是给这些 form 提供持久化默认值** -- 把 "读 env 默认" 改成 "读 store 默认", 不新增消费链路. - **真正 net-new 的只有两块**: (a) `/run` engine 的 provider/key **热换** (engine 持单一 `Provider` 接口值, 启动时焊死, 要热换得加一层); (b) key 的 **encrypt-at-rest** (今天 key 裸在 env, 要落库就必须加密). 把这点写死: ADR 不是 "造一套运行时配置系统", 是 "加一个 store + 一层 provider 热换缝 + 加密, 把已存在的 env/form 默认值搬进来". ### 1.3 三档现实: 八大模块里只有一部分有真引擎消费者 PM 拍 "八大模块尽量都接". 但 recon (8 模块逐旋钮 grep 生产代码) 证实: 硬 "接" 无消费者的旋钮 = endpoint 返 200 / 落库 / 但引擎没反应的**假生效**. 故按三档诚实分类, 每个旋钮明示它在哪档, UI 上据此渲染状态 (真生效绿 / 待消费灰 / 纯展示虚): | 档 | 含义 | 旋钮 (引用 recon 的 file:line) | |---|---|---| | **真生效** | 引擎生产代码真读, 改了真变 | provider key: anthropic/openai/deepseek/minimax (`main.go:236/243/406/426`) · 模型路由 main/sub model (`QUOTE_*_MODEL` + form `handler:2138-2183`) · reflector/audit (`handler:2239-2245`, 仅 per-request, 无 env 默认) · vision provider/model (`main.go:475/502/495` + `handler:1595`) · 审计开关 (`QUOTE_AUDIT_ENABLED` + form `handler:2212`) · prompt 内容 `_audit.md`/`_vision.md`/main/sub (`prompts/save` -> `RawAuditPrompt`/`RawVisionPrompt`, 读 `handler:2111`) · OIDC SSO (`auth.New main.go:78`, JWT 验签 + tenant gate) | | **真存储待消费** | 可落库读回, 但引擎那头无消费者; 接了静默 no-op | gemini/openrouter/ollama key (provider 包**未被 platform binary import**, vision->gemini 是 UI 真错配) · scope-keyed preset cache (whsid/shiptypeId/msnID, ADR-0012 三层记忆回写未实现) · flow 草稿/发布/节点库 (create/update 返 id 不 INSERT) · per-tenant 计费聚合 (raw cost 落了, 无 rollup) · tenant CRUD · IM/webhook · vertical config | | **纯展示** | 无后端逻辑, UI 装饰 | RBAC 角色矩阵 (硬编码 `super-admin`, `server_p2_stubs.go:113`, 无 enforcement) · 实时 cost 视图 (cost 是引擎 output 不是配置旋钮) | 非显然处两条: (1) phase2 置灰的 **集成模块其实有真消费者** (OIDC SSO 活着); (2) 看着激活的 **RBAC / flows / 节点库其实是死的**. UI 的明暗与真假**不对齐**, 三档表就是用来纠这个错觉的. ### 1.4 为什么现在: 浏览器配置是 P2 UI 的门票 env var **做不了 per-tenant / per-scope 运行时配置** (运维要在浏览器为不同租户配 provider key / 模型路由). 这是从 "env 启动配置" 迁到 "store 运行时配置" 的真实动因 -- 不是为配置而配置, 是 P2 UI 的产品需求逼出来的. ### 1.5 单租户现实: 留前瞻列, 不造多租户 现实是单租户 (`/users/me` 硬编码 `t_default`, 租户隔离只在 gRPC 观测面, 业务/REST 路径无 row-level 隔离). 本 ADR 在 store 留 `tenant_id` 列默认 `t_default` 作最小前瞻, 但**不做 per-tenant UI / 不做 row-level 强制** -- 不为不存在的多租户过度设计. --- ## 2. 决策 / Decision ### 2.0 confirmed (设计前提, 不 relitigate) 1. **config 落 platform 层, 不进 core**: core/pkg/engine 零外依赖 + 场景中性 (CLAUDE.md 原则 8/9). 配置 store / 加密 / 热换 wrapper 全住 `platform/common`, 引擎只暴露已有的 `Provider flyto.ModelProvider` 接口缝. 2. **不走 `~/.flyto/settings.json`** (`core/pkg/config`): 那是 CLI/tui-local 文件层, 单机单进程; 服务端多副本要 store, 层不同别混. 3. **三档纪律**: 只把 "真生效" 档旋钮接到真消费; "真存储待消费" 落库 + UI 标灰 + 不假装生效; "纯展示" 不碰. (§1.3 表是契约.) ### 2.1 脊椎: 不可变 ConfigSnapshot + atomic.Pointer (范式级, 非三处补丁锁) 把 settings UI 可改的那部分配置 (provider 实例 per backend / 模型路由默认 per role / 审计开关 / vision 配置 / prompt preset 引用) 收成**一个不可变结构 `ConfigSnapshot`**, 放在 `atomic.Pointer[ConfigSnapshot]` 里. 三条消费 seam 全部**无锁只读**当前 snapshot. "应用一次配置变更" = 构造新 snapshot (copy-on-write, 只为变了 key 的 backend 重建 provider 对象) + 一次 `atomic.Pointer.Store` 原子替换. 语义: - **读是无锁热路径**: 每请求 `snap := cfgPtr.Load()` 拿到当前快照, 整请求用这一个一致视图. - **写是单写入者 copy-on-write**: 配置变更极稀 (运维偶尔点一下), 重建 provider 对象的代价可忽略. - **跨三 seam 原子一致**: 一次 swap, 三条 seam 下一个请求同时看到新配置, 不会出现 "key 换了 model 还旧" 的撕裂. - **在途请求用旧 snapshot 跑完**: 不打断正在跑的引擎, 干净的 "下一个请求生效". > **CLEVER**: copy-on-write 原子指针把 "三条 seam 各自加锁热更" 这个补丁味问题, 收敛成 "单一不可变快照原子替换" -- 读无锁, 写无撕裂, 跨 seam 一致性免费. 原方案 (三处 ad-hoc 锁) 见 §3. ### 2.2 三条消费 seam 都只读 snapshot | seam | 今天怎么配 | 改后读 snapshot 的什么 | 缝在哪 | |---|---|---|---| | **`/run` engine** | `engine.New` 焊死单一 `Provider` (`main.go:329`) | `snap.RunProvider` (provider+key+endpoint); model 仍走 per-request `WithModel` (`server.go:622`), 默认值取 `snap.RunModel` | **`ConfigurableProvider` wrapper** (实现 `flyto.ModelProvider`, 每次 `Stream` 读 `cfgPtr.Load().RunProvider` 委派). 这是 net-new 缝. | | **dispatch loop** | per-request form + `QUOTE_*_MODEL` env 默认 (`handler:2138-2245`) | `snap.SubProvider/MainProvider/AuditProvider` + `snap.SubModel/MainModel/AuditModel` 作 form 缺省 | handler 读 snapshot 取默认, form 仍可覆盖 (优先级: form > snapshot > 503) | | **vision** | `cfg.VisionExtractors` map (`main.go:475/502`) + form `vision_provider` | `snap.VisionExtractors` + `snap.VisionModel` | handler `visionExtractorFor` 从 snapshot 取 (`handler:1595`) | **关键分层 (advisor 纠偏)**: `ConfigurableProvider` wrapper **只热换 provider/key/endpoint, 换不了 model**. 因为 model-per-role 在 `engine.New` 冻死 (`Config.Model` / `ModelForRole` / memory-query model 全按值捕获, `flyto.Request` 无 role 字段). 所以: - **/run 的 model** 改变靠 per-request `WithModel(snap.RunModel)`, 不靠 wrapper. - **dispatch/vision 的 model** 本来就 per-request 可配, snapshot 只提供默认值. - wrapper 内**绝不**改写 `req.Model` (会让 `req.Capabilities` 对不上, `engine.go:2919` 按上游 model id 注入能力, 改了 model 但能力还旧 -> 错的 context-window/pricing). ### 2.3 持久化: engine_config (JSONB 明文) + engine_secrets (AES-256-GCM 密文), 镜像 sessionstore 新 store 包 `platform/common/internal/server/engineconfigstore/`, 形状照抄 sessionstore (interface + InMemoryStore + PostgresStore + Err sentinels). 双实现: **m2max staging 不配 `--postgres-dsn` 也能 InMemory 真生效做 demo**, Postgres 是持久层. DDL 追加进 `platformMigrations` (append-only, `db/pool.go:44`). **schema 分裂 -- 不加密非密**: 四类配置里只有 provider key 是 secret. 路由/开关/preset 是普通配置. ```sql -- 普通配置: JSONB 明文 CREATE TABLE engine_config ( tenant_id TEXT PRIMARY KEY, -- 单租户默认 t_default routes JSONB, -- 模型路由 per role audit JSONB, -- 审计开关 presets JSONB, -- prompt preset 引用 vision JSONB, updated_at TIMESTAMPTZ ); -- 密钥: AES-256-GCM 密文 BYTEA + per-row nonce CREATE TABLE engine_secrets ( tenant_id TEXT, name TEXT, -- anthropic / openai / deepseek / minimax / ... ciphertext BYTEA, nonce BYTEA, -- 每次 encrypt 新 12-byte crypto/rand key_prefix TEXT, -- 明文前 4 位, 给读回脱敏用 (读回永不解密) key_version INT, -- 留 rotation updated_at TIMESTAMPTZ, PRIMARY KEY (tenant_id, name) ); ``` upsert 用 `INSERT ... ON CONFLICT DO UPDATE` (原子, 镜像 sessionstore `postgres.go:60`). ### 2.4 加密: stdlib AES-256-GCM, master key fail-loud - **算法**: `crypto/aes` + `crypto/cipher` 的 AES-256-GCM (stdlib only, CLAUDE.md 原则 8; 不引 `golang.org/x/crypto/nacl`). 每次 encrypt 新 12-byte `crypto/rand` nonce, nonce 随密文存同行. - **master key**: 启动时经 `FLYTO_SECRET_MASTER_KEY` (32 字节 base64) 注入, 对齐现有 `--postgres-dsn` flag + compose-secret-env 运维模式. `key_version` 列留 rotation. - **fail-loud (advisor)**: master key 缺失但库里有密文 -> **启动直接 fail**, 绝不静默回落 env (否则 "改了 key 不生效" 复现). 这是 ADR-0006 fail-loud 纪律的延伸. - **诚实威胁模型 (写给 PM)**: encrypt-at-rest 防的是**库 dump / 备份泄漏** (拿到 `.sql` 也读不出 key). 防不了**活着的进程被攻破** -- 进程手里就握着 master key. 不把它卖成比实际强的安全 (产品哲学 "看上去强一档" 不适用于安全声明, 安全要诚实). ### 2.5 读回脱敏契约: 永不返回裸 key 镜像 `core/pkg/engine/secret_store.go` 的 "故意无 Get" 哲学: - **client 读** 返 `{masked string, set bool}`, **无任何明文代码路径**. masked 用 `engine_secrets.key_prefix` 渲染 (前 4 位 + `****`, 复用 `core/pkg/providers/shared/mask.go` 的 `MaskAPIKey`). 读回**根本不解密** (prefix 写入时就存了). - **server 用** 是独立内部路径: 读行 -> 解密 -> 直接注入 provider Config (`anthropic/provider.go:35` 的 `APIKey`, 该字段本身 GoString-masked, `%#v` 不泄漏), 永不返回给 client. - 生命周期: SET -> AES-GCM 加密 -> 存密文+nonce+prefix; SERVER-USE -> 读 -> 解密 -> 注入 provider; CLIENT-READ -> 返 `{MaskAPIKey(prefix), set:true}`. ### 2.6 env <-> store 优先级: store 盖 env, env 作 bootstrap seed 显式定 (advisor: 不定则每字段实现时重拍一次): - **store 有值盖 env**: 某 backend 在 store 配了 key/model, 用 store 的. - **env 作 bootstrap seed**: 首启 store 空时, 把现有 env (`ANTHROPIC_API_KEY` / `QUOTE_SUB_MODEL` 等) 播种进 store (或 store 空字段回落 env). 保证**今天的 compose 部署不改 env 就能继续跑**, 同时 UI 一旦写入即接管. - 迁移无痛: 部署不动 `.env`, 引擎照常起 (env seed); 运维在 UI 改一次, 该字段转由 store 接管. ### 2.7 配置端点 (REST, ADR-0002 业务通道) ``` GET /api/v1/config/engine -- 读当前配置 (secret 脱敏 {masked,set}, 含每旋钮三档 status) PUT /api/v1/config/engine -- 写路由/审计/preset/vision (JSONB 明文部分) PUT /api/v1/config/secrets/{name} -- 写一个 provider key (加密落库 + 重建该 provider + swap snapshot) DELETE /api/v1/config/secrets/{name} -- 删一个 key ``` 写端点的副作用链: 落库 -> 构造新 ConfigSnapshot (copy-on-write) -> `atomic.Pointer.Store` -> 下一个请求三 seam 生效. 端点返回也带每旋钮的三档 status, 让 `settings.tsx` 据此渲染真生效绿/待消费灰/纯展示虚. --- ## 3. 替代方案 / Alternatives 1. **三处 ad-hoc reconfig 锁** (recon 原始建议): wrapper 用 `sync.RWMutex` / dispatch 扩 `promptMu` / vision 改 map under lock. **否**: 三处分头加锁 = 补丁味 (违 PM "范式级修法不打补丁"); 跨 seam 无原子一致 (key 换了 model 可能还旧); 三套锁三套竞态面. snapshot 把三处收成一处原子 swap. (保留作 fallback: 若 snapshot 抽象被证明过重, 退回扩 `promptMu` 是机械动作.) 2. **重启生效 (declarative config + 重启)**: 改配置写文件/库, 重启容器加载. **否**: PM 要 "改了真生效", 重启容器对运维是重操作, 且多副本重启有窗口. 但**实现复杂度最低** -- 若热换 wrapper 被证明有坑, 这是安全退路. 3. **走 `~/.flyto/settings.json`** (`core/pkg/config` 文件层): **否**: 单机单进程语义, 服务端多副本不可共享; 且把 server secret 写进 CLI-local 文件层是层错置. 4. **只用 per-request form, 不建 store**: dispatch 已支持 form 覆盖, 极端做法是 settings 全靠前端每请求带参. **否**: provider key 不能每请求从浏览器带 (裸传 secret); 且无持久化默认值, 刷新即丢; `/run` 的 provider 根本没有 form 缝. 5. **把整个 QuoteDispatchConfig 塞进 snapshot**: **否**: 该结构含 prompt dir / store handle / 一堆非 settings-mutable 字段, 全搬进 snapshot 是大重构且无收益. snapshot 只收 **settings UI 可改的子集**, 其余字段留 QuoteDispatchConfig 原样. --- ## 4. 影响 / Consequences **正面**: - settings UI 从 mock 变真生效 (真生效档旋钮): 浏览器改 key/路由 -> 下一个请求引擎真用新值, 不重启. - 无锁读热路径 + 跨三 seam 原子一致 (snapshot 脊椎). - key encrypt-at-rest, 库 dump 不泄 key; 读回永不返裸 key. - env 作 seed, 现有 compose 部署零改动平滑迁移. - 三档表把 "UI 明暗 vs 真假不对齐" 的错觉纠正, PM 点每个模块清楚它到哪一档. **负面 / 代价 (诚实列)**: - 比扩 `promptMu` 多一层 `ConfigSnapshot` 抽象 (换来的是无锁 + 原子一致, 值). - **/run 的 model-per-role 仍受限**: wrapper 换不了 model, 只能靠 per-request `WithModel`; engine 自身的 `Config.Model`/`ModelForRole`/memory-query model 仍 `engine.New` 冻死 (要改得 per-request 或重建 engine). 模型路由对 dispatch/vision 完整, 对 /run 是 "默认值 + WithModel" 而非 engine 内部 role 表. - **encrypt-at-rest 不防活进程攻破** (进程握 master key). 安全边界要对 PM 讲清, 不夸大. - `autoRegisterProviderModels` 只在 `engine.New` 跑一次 (`engine.go:2008`): wrapper 热换到带新 model id 的 backend, registry 不认 -> `capabilities_missing` 回落默认能力. 缓解: 启动**预注册所有候选 model** (照 `main.go:319 registerQuoteDispatchModels` 已有做法). - engine.Config 标量字段 (FastMode/Effort/Thinking/Temperature 等) 在 run loop 无锁读 (`engine.go:2906`): 这些**不能**经本路径热换 (改 live engine.Config 会 race). 采样/思考类配置不在本 ADR 热更范围. --- ## 5. 验证 / Validation 验收线 (memory `feedback_verify_against_truth`): **"真生效" = UI 改旋钮 -> 跑引擎 -> 观测实际用的 provider/model 真的变了**, 不是 endpoint 200 + 落库. - **provider 热换 (/run)**: UI 配 anthropic key A -> `/run` 跑一句 -> 日志/响应确认用 A; 改成 key B (或换 provider) -> 下一个 `/run` 确认用 B, **进程未重启**. (key 错误时 fail-loud 报真 provider 错, 非静默.) - **模型路由 (dispatch)**: UI 设 `sub_model=X` 默认 -> 不带 form 跑 dispatch -> round 落库的 sub agent model = X; UI 改成 Y -> 下一个 dispatch = Y. - **审计开关**: UI 关审计 -> dispatch 无 audit round; 开 -> 有. 对照 `/rounds`. - **secret 脱敏**: `GET /config/engine` 返回任何 key 字段都是 `{masked, set}`, 抓包确认无裸 key; 库里 `engine_secrets.ciphertext` 是密文非明文. - **env seed**: 清空 store + 保留 env 启动 -> 引擎照常跑 (env seed 生效); UI 写入后 -> 转用 store 值. - **三档纪律**: "真存储待消费" 旋钮 (如 gemini key) 在 UI 标灰 + 写入返回 status=persist_only, 不渲染成绿色 "已生效". - 单测: store 双实现 (InMemory + PG) round-trip + ON CONFLICT upsert; AES-GCM encrypt/decrypt + nonce 唯一 + master key 缺失 fail; snapshot atomic swap 并发读不撕裂 (`-race`). - **Live smoke 已过 (2026-06-06, 对真值非自评)**: 真 binary 本地起 (InMemory + 临时 key, FLYTO_LLM_PROVIDER=openai -> m5max Gemma, 真验 key 返 401). GET /config/engine 显 openai masked `10jl****` + run route 真. 好 key -> POST /agent/run 真流 text_delta+done. **PUT 坏 key (不重启) -> /run 返 "Invalid API key" error; PUT 换回 -> /run 恢复 done**. 全程同一进程未重启 = 验收线 (改 key -> 活引擎下一个请求真用新 key) 对真值跑通. 日志 0 `capabilities_missing` (env-seed 在 engine.New 前填好 run provider, autoRegister 见真 model 表). --- ## 6. 触发重新评估的条件 / Trigger conditions - 真要 per-tenant 配置 (多租户从单租户转真): `tenant_id` 列已留, 升级为 per-tenant store key + UI 租户切换. - 模型路由要对 /run 的 engine 内部 role 表生效 (不止 WithModel): 要么引擎暴露运行时 `SetModelForRole`, 要么按 tenant 重建 engine -- 触发时再评估引擎侧改动. - 配置项膨胀到采样/思考类也要热更: engine.Config 标量字段的 race 要先解 (引擎侧加读锁或快照化), 超出本 ADR. - snapshot 抽象被证明过重: 退回 §3 alt-1 (扩 promptMu) 是机械动作. - key rotation 真要做: `key_version` 列已留, 补 re-encrypt 迁移. --- ## 7. 工程量 / Engineering footprint - **net-new**: `engineconfigstore/` 包 (interface + InMemory + Postgres + Err, 镜像 sessionstore, ~3 文件) · `ConfigSnapshot` + `atomic.Pointer` + `ConfigurableProvider` wrapper (platform/common, ~1-2 文件) · AES-256-GCM helper (~1 文件) · 4 个 config 端点 handler · DDL 追加 platformMigrations. - **改动**: `cmd/common/main.go` (env seed + 构造 wrapper 作 engine.Provider + snapshot 初始化 + 端点注册 + master key flag) · `quotedispatch_handler.go` (sub/main/audit/vision 默认值改从 snapshot 读, form 覆盖逻辑不动) · `server.go` (handleAgentRun 取 `snap.RunModel` 作 WithModel 默认) · swagger 重生. - **前端**: `settings.tsx` 从 mock 接 4 端点, 按三档 status 渲染. - **部署**: 后端改了**不随前端 `docker cp` 生效**, 走 rebuilt binary / fastpush (memory `feedback_no_tag_just_fastpush`); 注入 `FLYTO_SECRET_MASTER_KEY` env. - **不碰**: core/pkg/engine (零外依赖 + 中性, wrapper 全在 platform); "真存储待消费" 档的消费链路 (本 ADR 只落库不接消费). --- ## 8. 修订记录 / Revision history - **v1 (2026-06-06, Proposed)**: 初稿. 脊椎 = 不可变 ConfigSnapshot + atomic.Pointer (三 seam 无锁只读, 原子 swap), 取代三处 ad-hoc 锁 (§3 alt-1). 三条 seam (run wrapper / dispatch / vision) 映射 + wrapper 只热换 provider/key/endpoint 不换 model 的分层 (advisor 纠偏). 持久化 engine_config JSONB + engine_secrets AES-256-GCM 双表, 镜像 sessionstore 双实现. master key fail-loud + 诚实威胁模型. env<->store 优先级 (store 盖 env, env 作 seed). 单租户留 tenant_id 列不过度设计. 三档表 (§1.3) 作 settings 接线契约. 待 PM 确认形状 + ⏸️ 授权写 .go. - **v2 (2026-06-06, Accepted + 实现 + LIVE)**: 多实例 provider 模型 + 引擎能力暴露 (PM 交互敲定: "一个云 API 也可能很多个不同 KEY" + fmlx 飞驼 Mac 推理多台需填网址 + "引擎本身的 provider 能力/模型 spec 也得暴露"). 关键改动: - **单位从类型改实例**: v1 一把 secret/类型 (engine_secrets 按类型名) 是错的 -- 云可多 key/账号, 自托管 fmlx 多台 Mac 各一端点. 改为命名 ProviderInstance `{name, type, url, 加密key}`, 一类型多实例. 新表 engine_provider_instances (append-only, engine_secrets 弃写). RoleRoute.Provider -> Instance (路由指实例名). v1 三档假占位 (gemini/openrouter/ollama 占位) 砍掉 -- PM "不堆假数据", 只支持真编入的 6 类型. - **provider 集**: anthropic / openai (真 OpenAI, 默认 api.openai.com) / deepseek / minimax / openrouter (接进引擎, v1 是待消费) / **fmlx** (飞驼 Mac 推理, openai 兼容指实例 url, 多实例). 部署的 FLYTO_LLM_PROVIDER=openai+OPENAI_BASE_URL=m5max 迁成实例 fmlx-m5max. - **引擎能力暴露**: GET /config/providers = 每类型的模型 + spec (ContextWindow/定价/vision/thinking/caching, 来自 provider.Models()/ModelInfo) -> 前端模型路由从真 spec 选, 不硬编码假 MODEL_OPTIONS. fmlx 自托管无静态目录 (模型在实例上) -> 空 + needs_url, 正常 (PM 认可). - **端点**: GET/PUT /config/engine (路由按实例) + PUT/DELETE /config/instances/{name} + GET /config/providers. secret 永远脱敏. - **不变**: snapshot atomic.Pointer 脊椎 + ConfigurableProvider wrapper + AES-256-GCM + fail-loud + env-seed 优先级. - **状态**: 后端 + 前端实现 + go test ./... 全绿; LIVE 部署在 labtest staging (Postgres). dispatch (sub/main/vision) 默认下沉 + swagger 重生仍是 follow-up (§7).