sqlseed-web 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
sqlseed_web/AGENTS.md ADDED
@@ -0,0 +1,106 @@
1
+ # sqlseed_web 后端
2
+
3
+ 上层见 [包指南](../../AGENTS.md);前端另见 [static/AGENTS.md](static/AGENTS.md)。
4
+
5
+ v8 整体重建已完成;当前细化以 [可用性与 AI 辅助计划](../../../../docs/superpowers/plans/2026-09-07-workbench-usability-ai.md) 为准,保留 [v8 重建契约](../../../../docs/superpowers/plans/2026-09-07-web-v8-rebuild.md) 的样式基线;导航按后续设置评审扩展为四项。以下原有 `/api` 行为是后端兼容边界,不要求保留旧页面或导航;正式 `/api/workbench` 及可选 AI 的规则见后文。
6
+
7
+ ## 模块与 API 边界
8
+
9
+ - [app.py](app.py) 保留公开 `create_app` 和 console `main` 入口;[_application.py](_application.py) 实现工厂、中间件及 lifespan,挂载旧 `/api`、`/api/workbench` 与 `/api/workbench/ai` router、`/static`、`/` 和 `/api/health`。Managed worker 直接使用工厂模块,避免经 console 启动入口反向依赖 supervisor。保留静态资源 `Cache-Control: no-cache`,本项目没有 asset hashing。
10
+ - [api.py](api.py):原有 endpoints;[state.py](state.py):进程内 `UIState` singleton、连接、AI 会话覆盖与 jobs。
11
+ - [workbench.py](workbench.py) 是正式工作台 HTTP 契约;[workbench_schema.py](workbench_schema.py) 提供成组外键、完整约束、结构 hash 与签名生成的参数目录;[workbench_runtime.py](workbench_runtime.py) 提供确定性检查、预览和服务端顺序执行;[workbench_store.py](workbench_store.py) 保存版本化草稿及固定运行快照。
12
+ - `/api/meta/*` 提供 generators/params、hooks、providers、AI 状态、locales、dialects;不要复制 core 计数。参数来自 `BaseProvider._gen_*` 签名,pluggy firstresult 标记读 `fn.sqlseed_spec`。
13
+ - `/api/connections` 管理连接,新连接弹窗复用该接口;连接下的 tables/schema/mapping/yaml-template、topo-order、preview/fill/rows/query 保留旧 API 兼容。正式工作台通过 `/api/workbench` 检查、预览和运行。
14
+ - `/api/config/parse` 通过临时文件调用 core `load_config()`,`finally` 删除文件;`serialize` 使用 safe YAML。解析成功不表示 generator 名称有效,未知名称仍可在生成/验证时失败。
15
+ - `config_to_dict()` 使用完整 Pydantic `model_dump`;新增配置字段时仍需核对前端往返,避免手工白名单漏字段。
16
+ - `/api/fs/browse` 列出服务器目录,默认隐藏非 DB 文件,始终跳过 dotfiles;浏览器 file input 不能提供服务器绝对路径。
17
+
18
+ ## 连接与后台任务
19
+
20
+ - 连接请求恰好提供 `db_path`/`url` 之一。每个连接持有长生命周期 `DataOrchestrator`;连接失败时清理已注册对象。
21
+ - 同目标多连接是有效用法。旧 API 的长生命周期 orchestrator 保留独立 provider/locale;正式工作台的 provider/locale 属于配置,并按完整配置构建执行实例,不能用旧连接默认值覆盖文档。`group_key`、`group_index`、`group_size` 仅做显示分组,不是跨连接锁。
22
+ - [sqlite_target.py](sqlite_target.py) 统一 SQLite 的分组、写入准入与配置身份,按 SQLAlchemy 传给 sqlite3 的实际 URI 参数识别文件、具名共享内存和私有内存。普通文件路径的既有配置 hash 保持不变;共享内存按名称、私有内存按 conn_id 隔离,不能把未启用 `uri` 的 `mode=memory` 当作内存库。Web 不支持自定义 SQLite VFS;实际 URI 带 `vfs` 时在连接注册、数据库打开前拒绝,不能忽略 `memdb` 等 VFS 导致同名不同库被合并。
23
+ - SQLite URI 中解码后的 NUL、原始 TAB/CR/LF、无效 UTF-8 百分号编码也在注册前拒绝:SQLite 字符串截断与 Python URL 清理/替代解码不同,不能把异常编码折叠成另一目标。合法 UTF-8 与经过百分号编码的 TAB/CR/LF 文件名仍按真实路径识别。
24
+ - URI 文件路径须经平台路径转换;Windows 的 `/C:/...` 与普通 `C:\...` 指向同一文件。百分号只解码一次,文件名中的字面 `%41` 不能被误识别为 `A`;真实文件别名回归覆盖空格、百分号及编码盘符。
25
+ - fills 在后台线程运行,通过 `state.connection_operation(conn_id, job_id=...)` 领取已预留的任务;交互请求不排队等待连接锁,忙碌立即返回 HTTP 409。不得让同一 orchestrator 并发 `fill_table()`。
26
+ - `create_job()` 原子预留连接;同一数据库目标只能有一个填充任务,其他数据库可并行。SQLite 规范路径、file URI 和共享内存身份;PostgreSQL 只规范 URL 中已知端点及有效参数,不宣称识别 DNS 别名、service 或代理后的物理身份。worker 初始化、启动或终态持久化失败也必须释放任务占用。
27
+ - job 终态通过 `complete_job()` 原子发布 result、错误、行数和完成时间;非空 `GenerationResult.errors` 必须发布 error,不能报告成功。后台入口持有 `UIState.job_completion()`,未知程序错误继续传播,但必须在 finally 发布失败并释放占用;工作台的快照读取、执行和终态保存中断也不能留下 running 或误报 done。
28
+ - 创建任务与关闭连接受同一状态锁协调;排队/执行中的任务阻止关闭(HTTP 409),已失效连接的 worker 必须进入 error 终态。HTTP polling 使用任务快照。
29
+ - `GenerationResult.count` 是写入行数;`Job.rows_inserted` 是 Web 层字段。旧 `/api` fill 进度来自行数差值轮询,是近似值,不是核心进度 callback;不能把这种进度方案用于正式工作台的精确提交结果。
30
+ - SQL 标识符用 `validate_table_name()` + `quote_identifier()`;`run_query()` 当前只接受单条 SELECT,修改查询接口时保留该边界。
31
+ - 旧 `/api/connections/{id}/tables/{table}/schema` 的 `unique_columns`、`skippable` 和 `ColumnInfo` 驱动历史面板;FK 字段为 `column`/`ref_table`/`ref_column`,均是单数。新工作台必须使用独立完整 schema 契约,保留成组列映射和 namespace,不能沿用此简化形状。
32
+ - Web locale 使用 Faker 风格(`zh_CN`/`en_US`);`SUPPORTED_LOCALES` 与 `MimesisProvider.set_locale` 的映射保持一致。
33
+
34
+ ## 可选 AI 边界
35
+
36
+ - `sqlseed_ai` 必须在函数内 lazy import;metadata/config 缺失 AI 时返回 `available: false`,heal endpoints 用 HTTP 503。
37
+ - [workbench_ai.py](workbench_ai.py) 是正式工作台可选 AI 接口:配置与检测复用 env + 会话设置,分析复用 `AIConfig` / `SchemaAnalyzer`,返回待审阅补丁,不运行自动修复或填充。
38
+ - `/ai/suggest` 保留 JSON 响应兼容,`Accept: application/x-ndjson` 使用 [workbench_ai_stream.py](workbench_ai_stream.py) 发布实际 context/model/validation/preview 阶段与唯一终态。单次请求固定 AIConfig 快照;同连接 AI 门禁直到 worker 真正退出才释放,取消或 180 秒等待预算不等于硬中断 SDK 网络调用。ASGI 2.3 与 2.4 的断开监听不得竞争同一个 receive。
39
+ - AI 候选样例经实例级 DataStream 尝试预算和合作式取消检查,不全局修改重试常量。保留结构化 validation issues(表、列、生成器、约束),禁止暴露已有记录、密钥或原始 SDK 错误;catalog 必须解释 pattern 的正则语义及当前 provider 的 phone/template 差异。
40
+ - AI 范围支持整库、指定表多选、指定列多选及当前/勾选表快捷项,独立于生成范围。`allowed_targets` 限定修改列;上下文保留所在整表及必要上游结构、约束、生成器目录、全局引擎/语言和用户业务说明,不包含连接地址、凭据、row_count、运行时父键或已有记录。全部 `table_drafts` 参与保护和候选校验,不能因表未勾选丢失高级规则。schema/业务说明始终视作数据。
41
+ - 建议经过范围、表列、generator 目录、参数及 `ColumnConfig` 校验。新关系仅由 [workbench_ai_relations.py](workbench_ai_relations.py) 编译 copy/concat/product/date_offset 模板,验证类型、NULL、来源可用性及新旧 core DAG;保留 PK/FK/实际使用数据库默认值的列/计算列和已有 derived/native 规则,拒绝模型原始表达式、原生方法或文件路径。整份候选配置只读校验,相关补丁带 group_id 并原子审阅/应用;样例检查不宣称证明任意 SQL CHECK。分析前后复核 schema hash,网络期间释放连接操作锁。
42
+ - 新旧 AI 配置响应均不能回传 API key,只给是否配置的标记;修改设置时空密钥保留已有会话密钥,旧接口显式空请求仍重置会话覆盖。密钥不得保存进工作台文档或运行记录。
43
+ - `/heal/validate`、`/heal/repair`、`/heal/auto` 分别委托 Layer 2、3、5;保持修复算法在 AI 包,不搬入 Web。
44
+ - auto-heal 复用 `sqlseed_ai.runtime` 的 `build_ai_config()`、`build_llm_client()`、`build_heal_orchestrator()`;不导入 CLI 私有工厂。共享服务使用普通 Python 异常,CLI/Web 入口各自转换错误,修改签名时一并检查调用者与资源释放。
45
+ - 有效 AI 配置从 env 合并会话 override,再由单次 auto-heal 请求覆盖;空 override 值回退到 env。metadata 与 `/api/ai/config` 的状态应一致。
46
+ - `/api/ai/test-connection` 探测 `resolve_base_url().rstrip('/') + '/models'`;本地 Ollama/LM Studio 只要求服务可达,不要求用户填写 API key。
47
+ - `AI_BACKENDS` 给出基础展示顺序,heal 页面会把有效 backend 提到首位;两个地方共同决定用户看到的顺序。
48
+ - auto-heal 产出 YAML,不执行数据填充;`_CountingLLMClient` 记录真实 LLM 调用次数,确定性流程可能为 0,不能把“完成”描述成必然调用过 LLM。
49
+ - AI 连接目标保持与 core 一致:含 `://` 的目标按 URL 传递,包括 `sqlite+pysqlite://`;不能用固定 dialect 前缀当文件路径判据。
50
+
51
+ ## 正式工作台不变量
52
+
53
+ - 文档经完整 `GeneratorConfig` 校验,未知 root/table 字段必须报错;目标仅由明确的连接注入,保存内容不带 `db_path`/`url` 或凭据。不能因为面板没有编辑器就丢弃高级配置。
54
+ - 保存校验 schema hash 和乐观 revision;开始运行时在 store 同一事务校验当前 revision 与快照,再保留不可变文档。检查和运行都重新读取真实结构及来源,不能相信前端的成功标记。
55
+ - 列表、打开和运行只接受当前 canonical `target_key`,不提供或接受旧身份 aliases。旧 URI hash 可能恰好属于另一个真实的 `file:` 前缀文件,不能仅凭当前连接写法推导旧 hash 并自动授权;没有可信身份版本的记录不得通过同 schema 或解析 target_label 猜库迁移。普通路径的旧 key 不变;身份发生变化的旧 URI 配置与运行仍保留可导出,由用户明确导入当前目标创建新配置,历史运行快照不改。历史记录未存原始连接或身份算法版本,旧错 hash 与真实字面 `file:` 路径 hash 的反向碰撞无法可靠区分,这是既有数据的限制,不自动推断或重绑。
56
+ - 结构图方向父→子;复合 FK 是同一条边的成组列映射。跨 namespace 或不存在的来源保留只读节点,不能把它们映射到同名默认 schema 表。
57
+ - SQLite rowid 分配优先使用 core 的 `ColumnInfo.is_rowid_alias` 事实,仅旧 metadata 缺失时使用兼容查询。部分索引不得进入无条件 `unique_constraints`;其条件保留在 `conditional_indexes` 并参与 schema hash,谓词变化会使旧检查失效。
58
+ - 空父表在所选生成计划内是合法依赖。预览不能虚构尚未生成的父键,但仍须验证独立字段的生成器参数和表达式。
59
+ - 未选父表存在有效引用键时可以只读引用;未选且缺少必需来源时才阻止对应生成。依赖检查返回来源是否可用、数量与范围事实,不向浏览器暴露实际父键列表。追加写入的自增 ID 由数据库分配,不能为改善预览展示而重置序列。
60
+ - 工作台的保存、检查和执行不依赖 AI;AI 仅在用户请求时分析并产出待审阅建议,用户选中应用后进入同一份配置,仍由确定性检查验证。执行保持 `fill_table(skip_ai=True)`,不能在写入过程中隐式更改已经确认的规则。SQLite 清空使用明确执行策略和单事务,不能通过 core 配置 clear_before 隐式开启;PostgreSQL 清空、任意服务器 Python transform、跨表通用循环等未接入能力必须明确阻止,不可忽略。
61
+ - Worker 持有连接操作锁并逐表执行,失败后其余表 `not_run`。已提交数量来自 `GenerationResult.count`,不能用总行数差冒充精确值。记录异常文本先脱敏,避免 SQLAlchemy 参数和连接密码落盘。
62
+ - Store 使用 WAL、短事务、独立连接;`get_store()` 按路径缓存,只有首次初始化恢复遗留任务为 `interrupted`,不得在每次轮询时打断活跃任务。中断记录 `row_counts_exact=False`。
63
+
64
+ ## 验证
65
+
66
+ 从仓库根显式运行 `pytest plugins/sqlseed-web/tests/ -q`。并发、schema 约束和配置往返应使用真实 SQLite;AI 可达性测试可替换 HTTP 请求。
67
+
68
+ ## 显式执行策略
69
+
70
+ - `workbench_execution.py` 负责只读清空规划。POST execution-plan 使用保存的 draft/revision/schema/config 绑定,返回实际表/行数、删除顺序、能力和 plan_hash。POST runs 固定 execution/plan_hash;append 为兼容默认,replace_selected 必须重新验证确认计划。
71
+ - SQLite replace 使用 SQLAlchemyAdapter.transaction(),父键读取、清空、自引用更新和批次写入共用连接;不关闭 FK、不自动 CASCADE、不在事务提交前报告已提交。失败回滚原行与序列,运行结果明确 rolled_back。PostgreSQL replace、范围外引用、触发器和未覆盖自引用等通过服务端能力检查阻止。
72
+ - 配置删除只删除可变草稿,不删除已提交运行快照、任务和业务记录;重命名/复制/删除均按 revision 检查。
73
+
74
+ - 2026-09-09 实测修正:AI DEFAULT 保护按当前实际生成模式判断;生成器主动提供值时可优化,真正省略使用 DEFAULT 时保持保护。PK/FK、计算列及已有派生/原生规则继续保护。追加失败只有完整精确计数才能创建剩余配置,扣除已提交行数,保留原快照与已完成表草稿,不自动提交;中断/未知计数/清空模式不能直接推导剩余量。
75
+
76
+ - 自定义映射/enrichment 涉及 DEFAULT 时,AI 助手先调用只读 eligibility 预检,并与 suggest 共用实际规则解析;响应只含生成模式,不含样例或父键。普通配置不增加请求;编辑、关闭或离页后的旧结果不得打开可分析界面。
77
+
78
+ ## 2026-09-09 应用设置评审
79
+
80
+ - 用户已批准第四项主导航“设置”,由新 `pages/settings.js` 提供 AI 服务、插件与版本;不恢复旧 meta/heal 页面。普通设置由 Web 的 `ai_settings.py` 持久化,密钥保持环境变量或进程内存且按服务绑定。新环境接口由 `settings_environment.py` 只读汇总当前 Python 环境。
81
+ - 工作台 AI 助手仅展示服务/模型摘要与设置入口;范围、业务说明、分析和审阅继续留在助手。`ai-handoff.js` 只在内存保存明确往返的上下文,身份/epoch/schema/生命周期失效时拒绝恢复。设置检测草稿不保存,不以模型列表成功声称推理成功;保存/检测防重复,迟到响应不覆盖新页面。
82
+ - AI 普通设置字段为 backend/model/base_url;`SQLSEED_WEB_SETTINGS_PATH` 可覆盖。UI key 不入磁盘,不随跨 endpoint 切换继承,清除只在当前进程有效。配置表单不把同一进程中的设置称为浏览器私有。
83
+ - `preview.js` 重新预览保留上次 DOM、选中表和滚动;状态标明旧结果,失败保留旧结果。`preview.css` 只提供首载占位和状态高度。使用当前行为回归与实际浏览器尺寸核验,不能把历史测试数当成本轮验收。
84
+
85
+ ## 2026-09-10 环境状态契约
86
+
87
+ - 环境条目保留 `packages`/`providers`,补充 `category`、`requirement`、`dependency_ids`、`description`、`install_command` 和 `guidance`。这些是当前产品展示范围内的关系,不是全量 Python 依赖图;版本与 Python 信息继续动态读取。Base 属于内置,Faker 是 Core 必需依赖,Mimesis 可选。
88
+ - AI 配置读取、保存和检测返回 `availability_status`(`available`/`not_installed`/`import_error`),保留原 `available`。Web 可选能力要求发行包存在且模块可导入;卸载后缓存模块或 spawn 继承的 editable 源码路径不能让缺包显示可用。已安装加载失败引导修复,不误报未安装,异常文本不回显。
89
+ - AI 可用性还需无调用检查当前 Web 所需的 AIConfig 字段、call_llm(stage=...) 与共享 runtime 工厂;旧包可导入不等于接口兼容。Web ai extra 和受管安装目标要求 `sqlseed-ai>=0.2.4.dev0`,不可回退安装缺少工作台接口的 0.2.3。维护进程只读取 metadata,不导入正在变更的包。
90
+ - 缺失/异常 AI 仍返回脱敏 `effective` 普通设置,但禁止检测、保存、eligibility 和 suggest;错误带 `component_id=ai`、`recovery_action=install/repair`。`/api/meta/providers` 保留 available 数组并增加 statuses 事实对象。选中缺失 Mimesis 的配置允许保存,check/preview/执行复核返回 `provider_not_installed` 或 `provider_import_error`,不能静默换引擎。
91
+
92
+ ## 只读当前数据
93
+
94
+ - `workbench_data.py` 提供有界 GET 分页(limit 1–100、offset 非负)和运行目标的已注册连接匹配。读取先领取 connection_operation;run_id 存在时检查 target_key 与运行表范围,不能借活动连接读取另一目标。匹配连接列表不连接或反射数据库。
95
+ - 当前数据使用实际目录白名单、标识符引用、参数化分页和完整主键排序;空表保留列信息。BLOB、Decimal、超出 JavaScript 安全范围的整数及非有限浮点数保留可读值,driver 异常脱敏。接口不记录本次新增主键,也不提供写操作。
96
+
97
+ ## 可选组件自动管理
98
+
99
+ - `supervisor.py` 在默认启动时持有监听 socket 与管理状态,`managed_worker.py` 分别启动业务/维护 worker,`worker_control.py` 以有界匿名 IPC 传递控制与临时会话。保留原 NDJSON,不代理 HTTP。旧 `--manage-plugins` 仅兼容入口;普通网页无需命令切换或手动重启。
100
+ - 维护 worker 业务 `/api/` 一律拒绝,仅保留 health、environment 与 `/api/settings/plugins/*`;HTML 固定标识用于首次导航,API 门禁不能依赖前端,受管维护标识不得永久锁定恢复后的导航。
101
+ - `plugin_environment.py` 只接受当前解释器与可写独立 virtualenv,解析真实 distribution metadata/Requires-Dist;不使用 import 缓存判断新安装状态。系统/只读/共享系统包/环境外 metadata 不可管理,但保留普通 Web。
102
+ - `plugin_management.py` 白名单仅 ai/cli/mcp/mimesis。管理请求检查 loopback client/Host;POST 必须同 Origin 及 token。计划绑定五分钟内 metadata 快照,一次领取。Core/Web/Faker/Base 不接受操作,不自动卸载依赖。`supervised_plugins.py` 仅在新业务就绪后发布任务终态,恢复失败保留页面与只恢复服务的重试入口。
103
+ - 默认 supervisor 持有独占环境锁;子进程保留同一 flock 描述符,父 IPC 断开后关闭准入、自然排空工作再退出。外部正常 app 持共享锁,旧维护 app 持独占锁。锁不是外部 pip/Python 进程的强制协调器,不能声称阻止旧版本或任意外部进程。
104
+ - `runtime_lifecycle.py` 对所有非管理 HTTP 和真实后台线程计数;原子空闲检查失败返回 409,不强杀生成/AI 线程。`runtime_session.py` 只经内存和 IPC 保存原连接身份、凭据、provider/locale 与完整 AI 覆盖;成功后清除原始快照。内存 SQLite 阻止操作,部分恢复失败单独报告且不创建缺失数据库。
105
+ - `plugin_process.py` 使用受控 argv、固定环境、冻结版本 constraints、wheel-only、超时与有界脱敏输出;不得引入任意命令、路径、package spec 或 pip 私有 API。测试真实 pip/uv 只能操作临时 virtualenv 与离线测试 wheel,不得修改当前用户环境。
106
+ - 首版维护包变更仅支持 macOS/Linux;Windows 保留普通 Web 与 PowerShell 手动命令,不能在未实现子进程树超时回收前开放界面操作。
@@ -0,0 +1,24 @@
1
+ """sqlseed-web: web UI for the sqlseed test-data generation toolkit.
2
+
3
+ A FastAPI application that wraps ``DataOrchestrator`` and the sqlseed-ai
4
+ self-healing subsystem (Layers 1-5) with an HTTP API and a dependency-free
5
+ static frontend. It serves two purposes:
6
+
7
+ 1. A visual workbench (schema browsing, column-config editing, preview,
8
+ fill execution, data viewing, YAML round-tripping).
9
+ 2. An acceptance cockpit for the project itself: every core feature
10
+ (9-level mapping, constraint solving, contract validation, repair
11
+ strategies, auto-heal pipeline) is exposed as an observable endpoint,
12
+ making regressions visible without writing scripts.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __version__ = "0.1.0"
18
+
19
+
20
+ def main() -> None:
21
+ """Console-script entry point (``sqlseed-web``)."""
22
+ from sqlseed_web.app import main as run_server
23
+
24
+ run_server()
@@ -0,0 +1,8 @@
1
+ """``python -m sqlseed_web`` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlseed_web.app import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,205 @@
1
+ """FastAPI application factory shared by ASGI hosts and managed workers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ from fastapi import FastAPI, HTTPException, Request
12
+ from fastapi.exception_handlers import request_validation_exception_handler
13
+ from fastapi.exceptions import RequestValidationError
14
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+
17
+ from sqlseed_web.plugin_management import ManagementService, PluginManager, _loopback, guard_request
18
+ from sqlseed_web.plugin_management import router as plugin_router
19
+ from sqlseed_web.settings_environment import router as settings_router
20
+
21
+ _STATIC_DIR = Path(__file__).parent / "static"
22
+
23
+
24
+ def _loopback_host_forbidden(hosts: list[str], forbidden: bool) -> bool:
25
+ try:
26
+ host = urlsplit(f"//{hosts[0]}") if len(hosts) == 1 else None
27
+ forbidden = forbidden or host is None or not _loopback(host.hostname or "")
28
+ if host is not None:
29
+ forbidden = forbidden or bool(host.username or host.password or host.path or host.query or host.fragment)
30
+ _ = host.port
31
+ except ValueError:
32
+ forbidden = True
33
+ return forbidden
34
+
35
+
36
+ def _business_request_forbidden(request: Request, supervised_worker: bool) -> bool:
37
+ hosts, origins = request.headers.getlist("host"), request.headers.getlist("origin")
38
+ expected = f"{request.url.scheme}://{hosts[0]}" if len(hosts) == 1 else None
39
+ forbidden = expected is None or len(origins) > 1 or bool(origins and origins[0] != expected)
40
+ forbidden = forbidden or request.headers.get("sec-fetch-site") == "cross-site"
41
+ # The default launcher owns a loopback listener. Do not let a DNS
42
+ # rebinding Host turn it into an attacker's apparently same-origin API.
43
+ # External ASGI deployments retain their own Host/proxy policy.
44
+ server = request.scope.get("server")
45
+ if supervised_worker and server and _loopback(server[0]):
46
+ forbidden = _loopback_host_forbidden(hosts, forbidden)
47
+ return forbidden
48
+
49
+
50
+ def _maintenance_response(
51
+ request: Request, manager: ManagementService, manage_plugins: bool, supervised_worker: bool
52
+ ) -> JSONResponse | None:
53
+ path = request.url.path
54
+ management_path = path.startswith("/api/settings/plugins/")
55
+ if (
56
+ path.startswith("/api/")
57
+ and not management_path
58
+ and not manage_plugins
59
+ and _business_request_forbidden(request, supervised_worker)
60
+ ):
61
+ return JSONResponse(
62
+ status_code=403,
63
+ content={"detail": {"code": "cross_origin_forbidden", "message": "业务请求必须来自当前工作台页面。"}},
64
+ )
65
+ if (
66
+ manage_plugins
67
+ and path.startswith("/api/")
68
+ and not (management_path or path in {"/api/settings/environment", "/api/health"})
69
+ ):
70
+ return JSONResponse(
71
+ status_code=503,
72
+ content={
73
+ "detail": {
74
+ "code": "plugin_maintenance",
75
+ "message": (
76
+ "组件操作进行中,业务服务将自动恢复。"
77
+ if supervised_worker
78
+ else "当前服务处于插件维护模式;请正常重启 Web 后使用工作台。"
79
+ ),
80
+ }
81
+ },
82
+ )
83
+ if management_path or manage_plugins:
84
+ try:
85
+ guard_request(request, manager)
86
+ except HTTPException as exc:
87
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
88
+ return None
89
+
90
+
91
+ def _configure_middleware(
92
+ app: FastAPI, manager: ManagementService, manage_plugins: bool, supervised_worker: bool
93
+ ) -> None:
94
+ if supervised_worker and not manage_plugins:
95
+ from sqlseed_web.runtime_lifecycle import RuntimeAdmissionMiddleware
96
+
97
+ app.add_middleware(RuntimeAdmissionMiddleware)
98
+
99
+ # no-cache for the ES-module frontend: this app has no build pipeline or
100
+ # asset hashing, so browser-cached stale JS silently breaks new deploys
101
+ # (modules were observed serving 304-fresh while a cached sibling served
102
+ # old code). Always revalidate against the server instead.
103
+ @app.middleware("http")
104
+ async def _no_cache_static(request: Any, call_next: Any) -> Any:
105
+ response = await call_next(request)
106
+ path = request.url.path
107
+ if path == "/" or path.startswith("/static"):
108
+ response.headers["Cache-Control"] = "no-cache"
109
+ response.headers["X-Frame-Options"] = "DENY"
110
+ response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
111
+ return response
112
+
113
+ @app.middleware("http")
114
+ async def _maintenance_admission(request: Request, call_next: Any) -> Any:
115
+ management_path = request.url.path.startswith("/api/settings/plugins/")
116
+ if (failure := _maintenance_response(request, manager, manage_plugins, supervised_worker)) is not None:
117
+ return failure
118
+ response = await call_next(request)
119
+ if management_path or manage_plugins:
120
+ response.headers["Cache-Control"] = "no-store"
121
+ response.headers["X-Frame-Options"] = "DENY"
122
+ return response
123
+
124
+
125
+ def create_app(
126
+ *,
127
+ manage_plugins: bool = False,
128
+ management_service: ManagementService | None = None,
129
+ supervised_worker: bool = False,
130
+ ) -> FastAPI:
131
+ """Build the sqlseed-web application (API + static frontend)."""
132
+ manager: ManagementService = management_service or PluginManager(enabled=manage_plugins)
133
+
134
+ @asynccontextmanager
135
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
136
+ if isinstance(manager, PluginManager):
137
+ manager.start()
138
+ try:
139
+ yield
140
+ finally:
141
+ if isinstance(manager, PluginManager):
142
+ manager.stop()
143
+
144
+ app = FastAPI(
145
+ title="sqlseed-web",
146
+ version="0.1.0",
147
+ description="Web workbench and acceptance cockpit for the sqlseed test-data toolkit.",
148
+ lifespan=lifespan,
149
+ )
150
+ app.state.plugin_manager = manager
151
+ app.state.metadata_only = manage_plugins
152
+ _configure_middleware(app, manager, manage_plugins, supervised_worker)
153
+
154
+ @app.exception_handler(RequestValidationError)
155
+ async def settings_validation_error(request: Any, exc: RequestValidationError) -> Any:
156
+ # FastAPI includes rejected inputs in its default validation response;
157
+ # passwords and credential-bearing invalid URLs must never be echoed.
158
+ if request.url.path in {"/api/workbench/ai/config", "/api/workbench/ai/test"}:
159
+ return JSONResponse(
160
+ status_code=422,
161
+ content={
162
+ "detail": {
163
+ "code": "invalid_ai_settings",
164
+ "message": "AI 设置字段无效;Base URL 必须是无认证信息的 HTTP(S) 地址。",
165
+ }
166
+ },
167
+ )
168
+ return await request_validation_exception_handler(request, exc)
169
+
170
+ app.include_router(settings_router)
171
+ app.include_router(plugin_router)
172
+ if not manage_plugins:
173
+ from sqlseed_web.api import router
174
+ from sqlseed_web.workbench import router as workbench_router
175
+ from sqlseed_web.workbench_ai import router as workbench_ai_router
176
+ from sqlseed_web.workbench_data import router as workbench_data_router
177
+
178
+ app.include_router(router)
179
+ app.include_router(workbench_router)
180
+ app.include_router(workbench_ai_router)
181
+ app.include_router(workbench_data_router)
182
+ app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
183
+
184
+ @app.get("/", response_model=None)
185
+ def index() -> FileResponse | HTMLResponse:
186
+ if manage_plugins:
187
+ marker = (
188
+ 'data-plugin-supervised-maintenance="true"' if supervised_worker else 'data-plugin-maintenance="true"'
189
+ )
190
+ return HTMLResponse(
191
+ (_STATIC_DIR / "index.html")
192
+ .read_text(encoding="utf-8")
193
+ .replace(
194
+ "<html",
195
+ f"<html {marker}",
196
+ 1,
197
+ )
198
+ )
199
+ return FileResponse(_STATIC_DIR / "index.html")
200
+
201
+ @app.get("/api/health")
202
+ def health() -> dict[str, str]:
203
+ return {"status": "ok"}
204
+
205
+ return app
@@ -0,0 +1,290 @@
1
+ """Credential-free AI preferences and service-scoped in-memory authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from threading import RLock
10
+ from typing import TYPE_CHECKING, Any, Literal
11
+ from urllib.parse import SplitResult, urlsplit
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
14
+
15
+ from sqlseed_web.settings_environment import require_ai_available
16
+ from sqlseed_web.workbench_store import _default_path
17
+
18
+ if TYPE_CHECKING:
19
+ from sqlseed_ai.config import AIConfig
20
+
21
+ from sqlseed_web.state import UIState
22
+
23
+ _FIELDS = ("backend", "model", "base_url")
24
+ _SETTINGS_LOCK = RLock()
25
+
26
+
27
+ def _validate_endpoint_components(parsed: SplitResult) -> None:
28
+ if (
29
+ parsed.scheme not in {"http", "https"}
30
+ or not parsed.hostname
31
+ or parsed.username is not None
32
+ or parsed.password is not None
33
+ ):
34
+ raise ValueError
35
+ if parsed.query or parsed.fragment:
36
+ raise ValueError
37
+
38
+
39
+ def http_endpoint(value: str) -> str:
40
+ """Accept plain HTTP endpoints, never URL-embedded credentials or tokens."""
41
+ if not (value := value.strip()):
42
+ return value
43
+ try:
44
+ parsed = urlsplit(value)
45
+ _validate_endpoint_components(parsed)
46
+ if (
47
+ any(char.isspace() or ord(char) < 32 for char in value)
48
+ or "\\" in value
49
+ or (parsed.port is not None and parsed.port < 1)
50
+ ):
51
+ raise ValueError
52
+ except ValueError:
53
+ raise ValueError("Base URL 必须是 HTTP(S) 地址;认证请使用 API Key 字段") from None
54
+ return value.rstrip("/")
55
+
56
+
57
+ class SettingsRequest(BaseModel):
58
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
59
+ backend: Literal["openai_compat", "google_ai_studio", "ollama", "lm_studio"]
60
+ model: str = Field(default="", max_length=200)
61
+ base_url: str = Field(default="", max_length=2000)
62
+ api_key: str = Field(default="", max_length=4000, repr=False)
63
+ clear_api_key: bool = False
64
+
65
+ @field_validator("base_url")
66
+ @classmethod
67
+ def validate_endpoint(cls, value: str) -> str:
68
+ return http_endpoint(value)
69
+
70
+ @field_validator("model", "api_key")
71
+ @classmethod
72
+ def strip_text(cls, value: str) -> str:
73
+ return value.strip()
74
+
75
+
76
+ def settings_path() -> Path:
77
+ """Follow the workspace data directory, with an independent path override."""
78
+ if configured := os.environ.get("SQLSEED_WEB_SETTINGS_PATH"):
79
+ return Path(configured).expanduser().resolve()
80
+ workspace = os.environ.get("SQLSEED_WEB_WORKSPACE_PATH")
81
+ return (Path(workspace).expanduser().resolve() if workspace else _default_path()).with_name("settings.json")
82
+
83
+
84
+ def read_preferences() -> dict[str, str]:
85
+ """Ignore unavailable or malformed preferences; never load credentials."""
86
+ try:
87
+ value = json.loads(settings_path().read_text(encoding="utf-8"))
88
+ if not isinstance(value, dict) or set(value) != set(_FIELDS):
89
+ return {}
90
+ parsed = SettingsRequest.model_validate(value)
91
+ return {key: str(getattr(parsed, key)) for key in _FIELDS}
92
+ except (OSError, ValueError, ValidationError):
93
+ return {}
94
+
95
+
96
+ def storage_info() -> dict[str, Any]:
97
+ return {
98
+ "kind": "user_file",
99
+ "path": str(settings_path()),
100
+ "fields": list(_FIELDS),
101
+ "api_key": "session_or_environment",
102
+ }
103
+
104
+
105
+ def unavailable_preferences(registry: UIState) -> dict[str, Any]:
106
+ """Show saved ordinary fields without loading an absent/broken AI plugin."""
107
+ values = {
108
+ "backend": os.environ.get("SQLSEED_AI_BACKEND", "openai_compat"),
109
+ "model": os.environ.get("SQLSEED_AI_MODEL", ""),
110
+ "base_url": os.environ.get("SQLSEED_AI_BASE_URL", os.environ.get("OPENAI_BASE_URL", "")),
111
+ }
112
+ with _SETTINGS_LOCK:
113
+ values.update(read_preferences())
114
+ override = registry.get_ai_override()
115
+ values.update({key: override[key] for key in _FIELDS if key in override})
116
+ try:
117
+ values["base_url"] = http_endpoint(values["base_url"])
118
+ except ValueError:
119
+ values["base_url"] = ""
120
+ return {**values, "api_key_present": False}
121
+
122
+
123
+ def _service(config: AIConfig) -> str:
124
+ try:
125
+ endpoint = http_endpoint(config.resolve_base_url())
126
+ parsed = urlsplit(endpoint)
127
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
128
+ return json.dumps([config.backend.value, parsed.scheme, parsed.hostname, port, parsed.path])
129
+ except ValueError:
130
+ return ""
131
+
132
+
133
+ def _apply(config: AIConfig, values: dict[str, str]) -> None:
134
+ from sqlseed_ai.config import AIBackend
135
+
136
+ if "backend" in values:
137
+ config.backend = AIBackend(values["backend"])
138
+ if "model" in values:
139
+ config.model = values["model"] or None
140
+ if "base_url" in values:
141
+ config.base_url = http_endpoint(values["base_url"]) or None
142
+
143
+
144
+ def credential_snapshot(config: AIConfig) -> AIConfig:
145
+ """Prevent downstream SDK setup from re-reading an unscoped environment key."""
146
+ from sqlseed_ai.config import AIConfig
147
+
148
+ class ServiceAIConfig(AIConfig):
149
+ def resolve_api_key(self) -> str | None:
150
+ if self.api_key:
151
+ return self.api_key
152
+ return {"ollama": "ollama", "lm_studio": "lm-studio"}.get(self.backend.value)
153
+
154
+ @property
155
+ def has_real_api_key(self) -> bool:
156
+ return bool(self.api_key) and self.backend.value not in {"ollama", "lm_studio"}
157
+
158
+ return ServiceAIConfig.model_validate(config.model_dump())
159
+
160
+
161
+ def resolve_settings(
162
+ registry: UIState,
163
+ draft: SettingsRequest | None = None,
164
+ *,
165
+ session_override: dict[str, str] | None = None,
166
+ ) -> tuple[AIConfig, dict[str, str]]:
167
+ """Resolve fields without network I/O; a secret is usable only at its source service."""
168
+ require_ai_available()
169
+ from sqlseed_ai.config import AIConfig
170
+
171
+ with _SETTINGS_LOCK:
172
+ override = registry.get_ai_override() if session_override is None else dict(session_override)
173
+ saved = read_preferences()
174
+ env = AIConfig.from_env()
175
+ config = env.model_copy(deep=True)
176
+ config.api_key = None
177
+ sources = {
178
+ "backend": "environment" if os.environ.get("SQLSEED_AI_BACKEND") or env.base_url else "default",
179
+ "model": "environment" if env.model else "none",
180
+ "base_url": "environment" if env.base_url else "default",
181
+ "api_key": "none",
182
+ }
183
+ for values, source in ((saved, "saved"), (override, "saved" if override.get("_settings_saved") else "session")):
184
+ _apply(config, values)
185
+ sources.update({key: source for key in _FIELDS if key in values})
186
+ # Remember the original binding of legacy unbound session credentials,
187
+ # then select a credential against the final draft endpoint exactly once.
188
+ key_service = override.get("_api_key_service", _service(config))
189
+ if draft is not None:
190
+ _apply(config, {key: str(getattr(draft, key)) for key in _FIELDS})
191
+ sources.update(dict.fromkeys(_FIELDS, "draft"))
192
+ config.api_key, sources["api_key"] = _select_credential(env, override, draft, _service(config), key_service)
193
+ # Validate environment endpoints too, before an SDK can receive credentials.
194
+ if config.base_url:
195
+ config.base_url = http_endpoint(config.base_url)
196
+ return credential_snapshot(config), sources
197
+
198
+
199
+ def _select_credential(
200
+ env: AIConfig,
201
+ override: dict[str, str],
202
+ draft: SettingsRequest | None,
203
+ final_service: str,
204
+ key_service: str,
205
+ ) -> tuple[str | None, str]:
206
+ """Choose one credential only after the final service binding is known."""
207
+ if draft is not None:
208
+ if draft.clear_api_key:
209
+ return None, "none"
210
+ if draft.api_key:
211
+ return draft.api_key, "draft"
212
+ if not final_service:
213
+ return None, "none"
214
+ if override.get("api_key") and key_service == final_service:
215
+ return override["api_key"], "session"
216
+ if not override.get("_api_key_cleared") and env.api_key and _service(env) == final_service:
217
+ return env.api_key, "environment"
218
+ return None, "none"
219
+
220
+
221
+ def _write_preferences(values: dict[str, str]) -> None:
222
+ path = settings_path()
223
+ path.parent.mkdir(parents=True, exist_ok=True)
224
+ temporary: Path | None = None
225
+ try:
226
+ with tempfile.NamedTemporaryFile(
227
+ mode="w", encoding="utf-8", dir=path.parent, prefix=".settings-", delete=False
228
+ ) as stream:
229
+ temporary = Path(stream.name)
230
+ json.dump(values, stream, ensure_ascii=False, indent=2)
231
+ stream.write("\n")
232
+ stream.flush()
233
+ os.fsync(stream.fileno())
234
+ temporary.replace(path)
235
+ finally:
236
+ if temporary is not None:
237
+ temporary.unlink(missing_ok=True)
238
+
239
+
240
+ def save_preferences(registry: UIState, body: SettingsRequest) -> None:
241
+ """Publish in-memory configuration only after the atomic disk write succeeds."""
242
+ with _SETTINGS_LOCK:
243
+ config, sources = resolve_settings(registry, body)
244
+ values = {key: str(getattr(body, key)) for key in _FIELDS}
245
+ session: dict[str, str | None] = dict(values)
246
+ session["_settings_saved"] = "1"
247
+ if sources["api_key"] in {"session", "draft"} and config.api_key:
248
+ session["api_key"] = config.api_key
249
+ session["_api_key_service"] = _service(config)
250
+ if body.clear_api_key or (registry.get_ai_override().get("_api_key_cleared") and not body.api_key):
251
+ session["_api_key_cleared"] = "1"
252
+ _write_preferences(values)
253
+ registry.set_ai_override(session)
254
+
255
+
256
+ def set_session_preferences(registry: UIState, values: dict[str, str | None]) -> None:
257
+ """Explicit nulls remove legacy overrides; omitted fields retain their value."""
258
+ with _SETTINGS_LOCK:
259
+ current, _ = resolve_settings(registry)
260
+ candidate = registry.get_ai_override()
261
+ candidate.pop("_settings_saved", None)
262
+ if candidate.get("api_key"):
263
+ candidate.setdefault("_api_key_service", _service(current))
264
+ for key in _FIELDS:
265
+ if key in values:
266
+ value = values[key]
267
+ if value and value.strip():
268
+ candidate[key] = value.strip()
269
+ else:
270
+ candidate.pop(key, None)
271
+ # Resolve removals against persisted/environment defaults without
272
+ # publishing this candidate or re-binding the previous service's key.
273
+ restored, _ = resolve_settings(registry, session_override=candidate)
274
+ body = SettingsRequest.model_validate(
275
+ {
276
+ "backend": restored.backend.value,
277
+ "model": restored.model or "",
278
+ "base_url": restored.base_url or "",
279
+ "api_key": values.get("api_key") or "",
280
+ }
281
+ )
282
+ config, sources = resolve_settings(registry, body, session_override=candidate)
283
+ candidate.pop("api_key", None)
284
+ candidate.pop("_api_key_service", None)
285
+ if sources["api_key"] in {"session", "draft"} and config.api_key:
286
+ candidate["api_key"] = config.api_key
287
+ candidate["_api_key_service"] = _service(config)
288
+ if body.api_key:
289
+ candidate.pop("_api_key_cleared", None)
290
+ registry.set_ai_override(dict(candidate))