From 4e60a4ca616846fbfc312932456e1a1e7e0a0388 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Thu, 3 Sep 2026 22:34:01 +0800 Subject: [PATCH] feat: customize model provider requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model connections can now save bounded JSON request headers and top-level body fields. Direct requests and Continue support both; local OpenCode and DeepSeek Harness use only native header support, while the managed remote OpenCode gateway applies both. Runtime, protocol, and authentication fields keep precedence. Custom values are ordinary connection settings, not credential storage. This migrates Runtime settings from v20 to v21 and updates the DeepSeek control and managed Runtime ACP capabilities. Release note: 模型连接详情新增自定义请求头和请求体;直连与 Continue 支持两者,本地 OpenCode 和 DeepSeek Harness 仅应用原生支持的请求头,受管远程 OpenCode 支持两者,界面会明确提示生效范围。 --- FEATURES.md | 12 +- FEATURES.zh-CN.md | 5 +- build/deepseek-harness-utility-smoke.cjs | 3 +- .../deepseek-harness/technical-design.md | 7 +- docs/features/direct-model-agent/README.md | 1 + docs/features/model-connections/README.md | 46 +++ docs/features/model-connections/progress.md | 38 ++ .../model-connections/technical-design.md | 87 +++++ docs/features/model-connections/ui-design.md | 38 ++ docs/features/remote-host/technical-design.md | 4 +- src/agent-daemon/agent-model-gateway.test.ts | 46 +++ src/agent-daemon/agent-model-gateway.ts | 74 ++-- src/agent-daemon/protocol-server.test.ts | 8 +- src/agent-daemon/protocol-server.ts | 15 +- src/main/agent/continue-host-adapter.test.ts | 36 +- src/main/agent/continue-host-adapter.ts | 16 + src/main/agent/create-runtime.test.ts | 10 +- src/main/agent/create-runtime.ts | 9 +- .../agent/deepseek-harness-acp-e2e.test.ts | 76 ++++ .../deepseek-harness-control-protocol.ts | 6 +- .../agent/deepseek-harness-runtime.test.ts | 7 +- src/main/agent/deepseek-harness-runtime.ts | 4 + .../deepseek-harness-utility-launcher.test.ts | 5 + .../deepseek-harness-utility-launcher.ts | 1 + src/main/agent/model-runtime.test.ts | 74 ++++ src/main/agent/model-runtime.ts | 168 +++++---- src/main/agent/opencode-runtime.test.ts | 41 ++- src/main/agent/opencode-runtime.ts | 8 +- src/main/agent/remote-model-gateway.test.ts | 53 +++ src/main/agent/remote-model-gateway.ts | 78 ++-- src/main/agent/runtime-e2e.manual.test.ts | 65 ++++ src/main/deepseek-harness-host.ts | 3 + .../remote-agent/managed-model-bridge.test.ts | 12 + src/main/remote-agent/managed-model-bridge.ts | 8 + .../managed-remote-acp-runtime.test.ts | 4 +- .../managed-remote-acp-runtime.ts | 8 +- ...d-remote-project-runtime-validator.test.ts | 8 +- ...anaged-remote-project-runtime-validator.ts | 15 +- .../protocol-remote-runtime-channel.test.ts | 2 +- .../protocol-remote-runtime-channel.ts | 15 +- src/main/runtime-settings-store.test.ts | 116 +++++- src/main/runtime-settings-store.ts | 342 +++++++++--------- src/renderer/src/SettingsPanel.test.tsx | 88 +++++ src/renderer/src/SettingsPanel.tsx | 180 ++++++++- .../src/i18n/locales/en-US/settings.ts | 16 + .../src/i18n/locales/zh-CN/settings.ts | 16 + src/renderer/src/styles.css | 7 + .../agent-protocol/control-contracts.ts | 6 + src/shared/contracts.ts | 21 ++ src/shared/model-bridge-contracts.ts | 15 +- .../model-request-customization.test.ts | 109 ++++++ src/shared/model-request-customization.ts | 248 +++++++++++++ tests/support/model-request-probe.ts | 94 +++++ 53 files changed, 2009 insertions(+), 365 deletions(-) create mode 100644 docs/features/model-connections/README.md create mode 100644 docs/features/model-connections/progress.md create mode 100644 docs/features/model-connections/technical-design.md create mode 100644 docs/features/model-connections/ui-design.md create mode 100644 src/shared/model-request-customization.test.ts create mode 100644 src/shared/model-request-customization.ts create mode 100644 tests/support/model-request-probe.ts diff --git a/FEATURES.md b/FEATURES.md index 45d7f69a..6423921a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -103,7 +103,7 @@ otherwise. the Agent tool-dispatch boundary, while Execute has all permissions of the selected SSH account. The Agent owns accepted Prompts, provider/tool rounds, Runtime processes, a stable model ledger, and a bounded - semantic transcript over a private Unix socket and ACP v4. Work continues on + semantic transcript over a private Unix socket and ACP v5. Work continues on the Host after Desktop exit, network loss, or local-process termination. Reconnection attaches only to the original controller, binding, and operation, then atomically merges provenance into the original conversation @@ -213,6 +213,14 @@ otherwise. a bounded real text or image-generation request and validates the generated result instead of testing HTTP reachability alone, so it may incur a small provider usage charge. +- [x] **Custom model request fields**: Each LLM connection accepts bounded + JSON objects for additional request headers and top-level body fields. + Direct requests and Continue support both; local OpenCode and DeepSeek + Harness use only their natively supported headers, while the managed SSH + OpenCode model gateway supports both. Runtime, protocol, and authentication + fields take precedence, and these ordinary connection settings must not be + used to store API keys or other secrets. See + [model request customization](./docs/features/model-connections/README.md). - [x] **Context usage and automatic compaction**: Direct models update usage from each successful provider call. Images and tool rounds use the same accounting, with estimation only when the provider omits usage. The UI @@ -509,7 +517,7 @@ otherwise. - [ ] **GoodBuddy Team Hub** (planned): An optional service for organizations, RBAC, project sharing, remote Agents, policy distribution, and tenant audit. - [ ] **SSH Host and remote execution-space release acceptance**: Host CRUD, - Host Key, encrypted credentials, Project UI, Workspace, OpenCode ACP v4, + Host Key, encrypted credentials, Project UI, Workspace, OpenCode ACP v5, Agent-owned Prompt/gateway/transcript, read-only Ask, full-account Execute, cancellation, exact detached-Agent reconnection, and release-only dual-architecture resource verification are wired. Current Linux x64 source diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 51538059..bb0a3751 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -29,7 +29,7 @@ - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,本机展示副本达到容量时只截断后续展示,不取消原生任务。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。 -- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 在 Agent 工具分发边界只允许读取工具,Execute 使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v4 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.16`,Desktop 发布候选为 `0.12.2`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。 +- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 在 Agent 工具分发边界只允许读取工具,Execute 使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.16`,Desktop 发布候选为 `0.12.2`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。 - [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。 - [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。 - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 @@ -38,6 +38,7 @@ - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。 +- [x] **模型请求定制**:每个 LLM 连接可填写有界 JSON 自定义请求头和顶层请求体。直连与 Continue 支持两者;本地 OpenCode 和 DeepSeek Harness 只使用各自原生支持的请求头,受管 SSH OpenCode 模型网关支持两者。Runtime、协议和认证字段始终优先,这些普通连接设置不能用于保存 API Key 或其他密钥。详见[模型连接请求定制](./docs/features/model-connections/README.md)。 - [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 - [x] **持久凭据保护**:API Key 由 Main 使用系统安全存储加密且不暴露给 Renderer。托管 SSH accepted Prompt 只把当前 profile 与密钥放入 Agent 内存,不写入 SSH 参数、远端环境或磁盘;其他路径仍在 Main 内使用。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。 - [x] **有界故障诊断**:Desktop 在用户数据目录中轮转保存启动、Runtime 和远程连接的固定阶段失败,最多 4 个 256 KiB 文件;GoodBuddy Agent 在各 installation 的私有 state 目录中轮转保存 daemon、连接、恢复和 Runtime 生命周期,最多 3 个 64 KiB 文件,并可通过固定 `diagnostics --installation-id` 命令读取。两端都只记录白名单阶段、稳定错误码/类型和固定短消息,不保存 Prompt、凭据、文件内容、路径、环境、SSH 参数或 Provider 原始响应;诊断写入失败不改变正常运行。 @@ -106,7 +107,7 @@ - [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。 - [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。 - [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。 -- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v4、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;最终 `0.11.14` 测试签名包再次验证两个已交付模型轮次、一个工具、一个终态、`latest=ACK` 与零 owner/journal 残留。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构和离线 GoodBuddy 传输的发布验收;门槛未满足时不宣称该路径已发布或通过正式发布验收。 +- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;最终 `0.11.14` 测试签名包再次验证两个已交付模型轮次、一个工具、一个终态、`latest=ACK` 与零 owner/journal 残留。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构和离线 GoodBuddy 传输的发布验收;门槛未满足时不宣称该路径已发布或通过正式发布验收。 - [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认。 ## 规划原则 diff --git a/build/deepseek-harness-utility-smoke.cjs b/build/deepseek-harness-utility-smoke.cjs index 09633343..b8d4efa8 100644 --- a/build/deepseek-harness-utility-smoke.cjs +++ b/build/deepseek-harness-utility-smoke.cjs @@ -17,7 +17,7 @@ const { const { app, utilityProcess } = require('electron/main') const protocol = 'goodbuddy.deepseek-harness.control' -const controlVersion = 2 +const controlVersion = 3 const byteProtocol = 'goodbuddy.deepseek-harness.byte-stream' const byteProtocolVersion = 1 const configuredHostPath = @@ -183,6 +183,7 @@ async function run() { provider: 'goodbuddy', model: 'qwen-plus', supportsImageInput: false, + requestHeaders: {}, harnessVersion: '0.1.0-rc.8', credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'], skillPackages: [], diff --git a/docs/features/deepseek-harness/technical-design.md b/docs/features/deepseek-harness/technical-design.md index 76972f1c..fd622ad1 100644 --- a/docs/features/deepseek-harness/technical-design.md +++ b/docs/features/deepseek-harness/technical-design.md @@ -254,7 +254,9 @@ Store 是否已经提交为准,确定性完成新目录或恢复旧目录, | `goodbuddy/native/snapshot` | Main → Control Plane | 从无 Agent scope 的 Host Registry 读取有界的原生 Tool/Skill 元数据,排除 GoodBuddy 分配项与请求级代理 | | `goodbuddy/shutdown` | Main → Control Plane | 停止接收新请求并有序清理 | -Utility 启动控制协议使用版本 2,严格携带 `supportsImageInput` 与固定 8 MiB 帧上限;版本 1 或缺少该字段的启动消息失败关闭,不能让 Host 自行猜测模型能力。扩展版本独立于 ACP 版本。握手响应至少包含: +Utility 启动控制协议使用版本 3,严格携带 `supportsImageInput`、模型连接的有界自定义 +Header 与固定 8 MiB 帧上限;旧版本或缺少必需字段的启动消息失败关闭,不能让 Host +自行猜测模型能力。扩展版本独立于 ACP 版本。握手响应至少包含: ```ts type GoodBuddyHarnessCapabilities = { @@ -431,6 +433,9 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接: - 模型名称不限制为 DeepSeek 品牌,由所选 OpenAI 兼容服务决定。 - 模型名称和服务地址由 Main 传入受控 Host。 - 图片能力只读取所选 GoodBuddy 模型连接的 `supportsImageInput`;Main、Utility 启动配置、ACP 能力和 Pi-AI 模型输入模态必须使用同一个布尔值。 +- 模型连接的自定义 Header 通过 Utility 启动配置交给 Pi-AI Provider;Pi-AI 没有受支持的 + 任意 Body 配置入口,因此 DeepSeek Harness 不接收自定义 Body。完整支持矩阵和合并规则 + 见[模型连接请求定制](../model-connections/technical-design.md)。 - API Key 继续保存在 GoodBuddy 加密设置中。 - 启动环境提供的部署连接只由 Main 自动解析,不在 Renderer 中显示为可选来源。 - 未显式指定 Harness 模型且没有可用的管理员预置连接时,Main 优先使用当前默认的兼容 GoodBuddy 模型连接;默认连接不兼容时使用首个兼容连接。只有不存在任何兼容连接时才提示用户前往模型或 Runtime 设置。 diff --git a/docs/features/direct-model-agent/README.md b/docs/features/direct-model-agent/README.md index a6899076..eb4783ac 100644 --- a/docs/features/direct-model-agent/README.md +++ b/docs/features/direct-model-agent/README.md @@ -44,6 +44,7 @@ Task、Job、Subjob、Run 和 Subagent 的对象关系以 ## 相关功能 +- [模型连接请求定制](../model-connections/README.md):定义连接级 Header/Body、合并优先级和 Runtime 支持矩阵。 - [本机工具环境](../local-tool-environment/README.md):为新进程提供 Node、Python 和 PATH。 - [助手工作栏与执行空间](../assistant-workbar/prd.md):定义本机/SSH 执行位置和活动归属。 - [远程主机与远程执行](../remote-host/README.md):定义当前托管 SSH Runtime 边界。 diff --git a/docs/features/model-connections/README.md b/docs/features/model-connections/README.md new file mode 100644 index 00000000..bb83461a --- /dev/null +++ b/docs/features/model-connections/README.md @@ -0,0 +1,46 @@ +# 模型连接请求定制 + +本功能负责 LLM 模型连接的自定义请求头和自定义请求体。用户可以在同一个连接中保存 +Provider 要求的附加元数据;GoodBuddy 只在直连链路或 Runtime 明确原生支持的链路中应用 +这些值,不为不支持的 Runtime 伪造透传能力。 + +## 文档导航 + +| 文档 | 权威职责 | +| --- | --- | +| [UI 设计](./ui-design.md) | 编辑入口、校验反馈、兼容性说明和无障碍行为 | +| [技术设计](./technical-design.md) | 契约、持久化、合并优先级、Runtime 支持矩阵和验证 | +| [功能进度](./progress.md) | 已验证实现、剩余工作和验证证据 | + +本次改动是现有模型连接的有界扩展,不新增独立业务对象或工作流,因此不单独建立 PRD、 +User Stories 和逻辑设计;本页定义产品边界,技术设计定义唯一的请求合并规则。 + +## 术语 + +| 术语 | 定义 | +| --- | --- | +| 自定义请求头 | 用户为一个 LLM 模型连接保存的附加 HTTP Header 字符串映射 | +| 自定义请求体 | 用户为一个 LLM 模型连接保存的附加 JSON 顶层字段 | +| Runtime 字段 | GoodBuddy 或 Runtime 为模型、消息、工具、流式、认证和协议生成的字段 | +| 直连 | GoodBuddy Main 直接向模型 Provider 发出请求 | +| 受管远程 OpenCode | 模型请求通过 GoodBuddy Agent 的可信模型网关发出的远程 OpenCode | + +## 产品边界 + +- 两项设置都位于“设置 → 模型连接 → LLM → 连接详情”底部,以 JSON 对象编辑。 +- 直连模型支持请求头和请求体,包括连接测试、文本、工具、摘要和图像生成请求。 +- Continue 原生支持请求头和请求体。 +- 本地 OpenCode 原生支持请求头,不使用自定义请求体。 +- DeepSeek Harness 的 Pi-AI Provider 原生支持请求头,不使用自定义请求体。 +- 受管远程 OpenCode 的可信模型网关支持请求头和请求体。 +- 使用 Runtime 自有配置时不引用 GoodBuddy 模型连接,因此不会应用连接级请求定制。 +- Runtime、协议和认证字段始终优先。用户不能用请求定制替换模型、消息、工具、流式或 + 认证信息。 +- 自定义值作为普通连接设置保存。API Key 和其他密钥必须使用专用凭据字段,不应写入 + 自定义请求头或请求体。 + +## 相关功能 + +- [直连模型 Agent](../direct-model-agent/README.md) +- [DeepSeek Harness Runtime](../deepseek-harness/README.md) +- [远程主机与远程执行](../remote-host/README.md) diff --git a/docs/features/model-connections/progress.md b/docs/features/model-connections/progress.md new file mode 100644 index 00000000..beeecfd1 --- /dev/null +++ b/docs/features/model-connections/progress.md @@ -0,0 +1,38 @@ +# 模型连接请求定制进度 + +## 2026-09-03 + +### 已完成 + +- 模型连接设置、v20 → v21 迁移、公开与 resolved profile 已接入有界自定义 Header/Body。 +- 设置页已提供两个 JSON 编辑器、就地校验、保存阻止、普通设置存储提示和 Runtime 支持范围。 +- 直连连接测试、文本、工具、摘要和图像请求已统一采用 Runtime 优先的浅合并。 +- Continue 1.5.47 已通过原生 `requestOptions.headers` 与 `extraBodyProperties` 接入两项配置。 +- bundled OpenCode 已通过 Provider `options.headers` 接入 Header,并确认没有发送未支持的 + 自定义 Body。 +- DeepSeek Harness 已通过控制协议 v3 把 Header 交给 Pi-AI Provider,没有接入未支持的 + 自定义 Body。 +- Main 与 Agent 远程模型网关已接入 Prompt-scoped Header/Body;`runtime/acp` capability + 已提升为 v5。 +- 功能入口、支持矩阵、合并优先级、普通设置存储边界和协议版本已写入功能文档及双语 + `FEATURES`。 + +### 已验证 + +- `npm run typecheck` +- `npm run lint` +- 20 个聚焦测试文件:527 项通过,4 项按环境跳过。 +- 全量 `npm test`:326 个测试文件通过,8 个文件按环境跳过;3506 项通过,55 项跳过。 +- bundled OpenCode 与 Continue 分别对 loopback Provider 验证 Chat Completions 和 + Responses。4 项原生探针全部通过:两者都发送自定义 Header;Continue 发送自定义 Body; + OpenCode 不发送不支持的 Body。 +- Main/Agent 远程网关测试验证了合并优先级、认证优先、profile digest 绑定和单次分发。 +- 三路清理审查完成;已复用共享协议路径、Header 规范化、ACP capability 常量和 JSON + 类型,并为 Renderer JSON 草稿增加解析前长度上限。 + +### 待验证 + +- 生产 `npm run build`。 +- 最小真实模型 Header/Body 探针。 +- DeepSeek Harness 真实 Provider Header 探针。 +- 当前源码 Agent bundle 在共享 Linux x64 Host 上的受管远程模型请求。 diff --git a/docs/features/model-connections/technical-design.md b/docs/features/model-connections/technical-design.md new file mode 100644 index 00000000..64302a94 --- /dev/null +++ b/docs/features/model-connections/technical-design.md @@ -0,0 +1,87 @@ +# 模型连接请求定制技术设计 + +## 1. 共享契约 + +`src/shared/model-request-customization.ts` 定义: + +- `ModelRequestHeaders`:字符串到字符串的映射。 +- `ModelRequestBody`:顶层 JSON 对象。 +- 两个 Zod schema、统一边界和合并函数。 + +模型连接输入、公开设置、Main 内 resolved profile 和远程 Prompt profile 复用这些类型。 +Runtime 设置文件版本为 21;版本 20 迁移为两个空对象,不改变已有连接或凭据。 + +## 2. 边界 + +请求头最多 64 项;名称最长 128 字符,值最长 4096 字符。名称必须是合法 HTTP token, +大小写归一后不能重复,值不能包含控制字符。 + +请求体编码后最多 64 KiB,最大深度 16、最大节点数 4096;对象或数组单层最多 256 项。 +键最长 256 字符,字符串值最长 32768 字符,数字必须有限。 + +下列请求头由认证、协议或传输层拥有,不能在连接中配置: + +`Authorization`、`X-API-Key`、`API-Key`、`Content-Type`、`Content-Length`、`Host`、 +`Connection`、`Transfer-Encoding`、`User-Agent`、`Anthropic-Version`。 + +下列请求体顶层字段由 GoodBuddy 或 Runtime 拥有,不能在连接中配置: + +`model`、`stream`、`messages`、`input`、`instructions`、`system`、`tools`、 +`tool_choice`、`parallel_tool_calls`、`max_tokens`、`max_output_tokens`、 +`stream_options`、`prompt`、`n`、`quality`、`response_format`。 + +## 3. 合并规则 + +请求头先装载用户值,再装载 Runtime 和协议值,最后注入认证值。名称比较不区分大小写, +因此 Runtime、协议和认证值始终覆盖同名用户值。 + +请求体只做一次顶层浅合并: + +```ts +const providerBody = { + ...connection.requestBody, + ...runtimeBody +} +``` + +不递归合并嵌套对象。最终请求仍需通过现有协议策略和请求大小校验。 + +## 4. Runtime 支持矩阵 + +| 请求路径 | 自定义 Header | 自定义 Body | 实现边界 | +| --- | --- | --- | --- | +| GoodBuddy 直连 | 支持 | 支持 | Main 在连接测试、文本、工具、摘要和图像请求中合并 | +| Continue | 支持 | 支持 | 使用 Continue 1.5.47 的 `requestOptions.headers` 与 `extraBodyProperties` | +| 本地 OpenCode | 支持 | 不支持 | 使用 OpenCode Provider `options.headers`;不生成未证实的 Body 配置 | +| DeepSeek Harness | 支持 | 不支持 | 使用 `dsh-llm-pi-ai` Provider 的 `headers`;Utility 控制协议 v3 传递 Header | +| 受管远程 OpenCode | 支持 | 支持 | Agent Prompt profile 传到可信模型网关后合并 | +| Runtime 自有配置 | 不适用 | 不适用 | 该模式不使用 GoodBuddy 模型连接 | + +对不支持自定义 Body 的 Runtime,设置仍保留在连接上,切换回支持路径时继续生效;当前 +Runtime 不接收也不发送该 Body。 + +## 5. 远程模型网关 + +非空请求定制随 Prompt-scoped model profile 发送,不写入 Agent 环境、命令行或模型调用 +ledger。模型 profile digest 绑定规范化后的 Header 和 Body,但继续排除 API Key。 + +Main 网关和 Agent 网关都执行相同的可信 profile 合并、协议策略检查和最终 Body 大小检查。 +认证 Header 在请求摘要生成后注入,避免凭据进入摘要或 ledger。受管远程 +`runtime/acp` capability 为 v5;旧 v4 Agent 不能接收带请求定制的新 Prompt profile, +由现有受管更新流程升级后再运行。 + +## 6. 凭据 + +API Key 继续由现有 Main 加密设置和 Prompt-scoped Agent 凭据路径管理。自定义 Header 和 +Body 是普通可见连接设置,不提供第二套通用秘密存储;UI 必须提示不要把密钥放入这两个 +JSON 对象,标准认证 Header 也由 schema 拒绝。 + +## 7. 验证 + +- 共享 schema:JSON 类型、边界、保留字段和优先级。 +- 设置存储:v20 → v21 迁移与 Header/Body round trip。 +- 直连:连接测试、流式文本、工具、摘要和图像请求。 +- Runtime:真实 bundled OpenCode/Continue 对 loopback Provider 的请求探针;DeepSeek + Utility 启动配置和 Pi-AI Provider 配置。 +- 远程:Main/Agent 网关合并、认证优先级、摘要绑定、大小限制和不重放。 +- UI:默认值、保存、无效 JSON 保留和 Runtime 支持说明。 diff --git a/docs/features/model-connections/ui-design.md b/docs/features/model-connections/ui-design.md new file mode 100644 index 00000000..70c7392d --- /dev/null +++ b/docs/features/model-connections/ui-design.md @@ -0,0 +1,38 @@ +# 模型连接请求定制 UI 设计 + +本文件定义模型连接请求定制的界面行为。全局视觉、表单和无障碍规则继续以 +[`UI-DESIGN.md`](../../../UI-DESIGN.md) 为准。 + +## 信息架构 + +“自定义请求头”和“自定义请求体”位于当前 LLM 连接详情的最底部、连接兼容性摘要之前。 +它们不新增页签、弹窗或高级模式。每个编辑器使用可纵向调整大小的等宽文本域,默认值为 +`{}`。 + +## 编辑与保存 + +- 请求头接受 JSON 对象,所有值必须是字符串。 +- 请求体接受 JSON 对象,值必须是有界、可序列化的 JSON。 +- 用户输入在编辑时原样保留。JSON 语法、类型、大小或保留字段无效时,文本域显示 + `aria-invalid` 和就地错误;点击保存时阻止写入,并在现有设置错误区域显示对应连接。 +- 保存成功后,编辑器使用两个空格缩进重新格式化已保存对象。 +- 新连接从两个空对象开始。 + +## 兼容性说明 + +请求头说明明确列出直连、Continue、OpenCode 和 DeepSeek Harness 在原生支持范围内使用 +该值。请求体说明明确列出直连、Continue 和受管远程 OpenCode 支持,并说明本地 +OpenCode 与 DeepSeek Harness 不使用该值。 + +同一区域还必须说明: + +- Runtime、协议和认证生成的字段优先。 +- 请求体只做顶层浅合并,不递归合并对象。 +- 自定义值按普通连接设置保存,不能用于保存 API Key 或其他密钥。 + +## 无障碍与键盘 + +- 每个文本域有稳定的可访问名称,并通过 `aria-describedby` 关联兼容性说明。 +- 无效状态不能只依赖颜色,必须同时提供错误文字和 `aria-invalid`。 +- 文本域遵循标准键盘编辑、选择、复制和粘贴行为;保存继续使用设置页唯一的主保存操作。 +- 焦点边框、字体、间距、颜色和深浅主题全部复用全局语义令牌。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 3f35fa11..a6c7e25b 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -248,7 +248,9 @@ Execute 直接启动已签名 Runtime: - 托管 SSH 的生产 Prompt 从一开始就由 Agent 持有 ACP `ClientSideConnection`、原始 Prompt Promise、Provider 轮次和 Runtime 进程。SSH relay 或 Desktop 进程不属于该 Promise 的生命周期,因此正常退出、强制结束 Desktop 或本机网络中断后,Host 仍能继续 - 后续模型/工具轮次。该路径要求 `runtime/acp` capability v4,即 Agent `0.11.14` 或更高。 + 后续模型/工具轮次。该路径现在要求 `runtime/acp` capability v5;v5 同时保证 Agent + 能理解 Prompt-scoped 模型连接的自定义 Header/Body,旧 v4 Agent 由现有受管更新流程 + 升级后再运行。 - Agent 把 ACP session update、permission decision 和唯一 Prompt 终态写入有界语义 transcript。Main 不恢复旧 JSON-RPC Promise,也不重发 `session/prompt`;Fresh Desktop 只对同一 controller/binding/operation 执行 takeover、attach 和 ACK-exclusive page。 diff --git a/src/agent-daemon/agent-model-gateway.test.ts b/src/agent-daemon/agent-model-gateway.test.ts index be797f28..7ec75fbc 100644 --- a/src/agent-daemon/agent-model-gateway.test.ts +++ b/src/agent-daemon/agent-model-gateway.test.ts @@ -122,6 +122,52 @@ describe('AgentModelGateway', () => { ledger.close() }) + it('merges trusted profile request customization without overriding Runtime fields', async () => { + const fetcher = vi.fn(async (_url, init) => { + const headers = new Headers(init?.headers) + expect(headers.get('x-tenant-id')).toBe('agent-tenant') + expect(headers.get('authorization')).toBe(`Bearer ${secret}`) + expect(JSON.parse(String(init?.body))).toEqual({ + max_output_tokens: 4_096, + metadata: { source: 'goodbuddy' }, + model: 'model-1', + temperature: 0.8 + }) + return new Response('{}') + }) + const { gateway, ledger } = setup(fetcher) + const customizedProfile: AgentPromptModelProfile = { + ...profile, + requestHeaders: { + 'x-tenant-id': 'agent-tenant' + }, + requestBody: { + temperature: 0.2, + metadata: { source: 'goodbuddy' } + } + } + const result = await gateway.dispatch( + { + ...context, + profile: customizedProfile + }, + { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ + model: 'model-1', + temperature: 0.8 + }) + ).toString('base64') + }, + new AbortController().signal + ) + + await result.acknowledgeDelivery() + expect(fetcher).toHaveBeenCalledOnce() + ledger.close() + }) + it('does not impose prompt-wide model-call or output-token quotas', async () => { const fetcher = vi.fn(async () => new Response( diff --git a/src/agent-daemon/agent-model-gateway.ts b/src/agent-daemon/agent-model-gateway.ts index ee71bb47..1831608d 100644 --- a/src/agent-daemon/agent-model-gateway.ts +++ b/src/agent-daemon/agent-model-gateway.ts @@ -1,6 +1,9 @@ import { createHash } from 'node:crypto' import type { DatabaseSync } from 'node:sqlite' -import type { AgentPromptModelProfile } from '../shared/model-bridge-contracts' +import { + modelBridgePathMatchesProtocol, + type AgentPromptModelProfile +} from '../shared/model-bridge-contracts' import { REMOTE_MODEL_GATEWAY_LIMITS, remoteModelGatewayRequestSchema, @@ -12,6 +15,12 @@ import { assertTextModelRequestPolicy, ModelRequestPolicyError } from '../shared/model-request-policy' +import { canonicalJson } from '../shared/agent-protocol/canonical' +import { + canonicalModelRequestHeaders, + mergeModelRequestBody, + mergeModelRequestHeaders +} from '../shared/model-request-customization' import { BoundedResponseTruncatedError, BoundedResponseTooLargeError, @@ -480,7 +489,12 @@ function prepareProviderRequest( body: Uint8Array canonicalRequest: string } { - if (!pathMatches(profile.protocol, request.path)) { + if ( + !modelBridgePathMatchesProtocol( + profile.protocol, + request.path + ) + ) { throw new AgentModelGatewayError( 'policy', 'Provider path does not match the prompt profile' @@ -498,6 +512,10 @@ function prepareProviderRequest( 'Provider request body is not UTF-8 JSON' ) } + const normalized = mergeModelRequestBody( + profile.requestBody ?? {}, + json as Record + ) try { assertTextModelRequestPolicy( { @@ -505,7 +523,7 @@ function prepareProviderRequest( model: profile.model, supportsImageInput: profile.capabilities.imageInput }, - json + normalized ) } catch (error) { if (!(error instanceof ModelRequestPolicyError)) { @@ -516,7 +534,6 @@ function prepareProviderRequest( 'Provider request does not match the prompt profile' ) } - const normalized = { ...(json as Record) } const outputField = profile.protocol === 'openai-responses' ? 'max_output_tokens' @@ -538,22 +555,37 @@ function prepareProviderRequest( requestedOutput === undefined ? maximumOutputTokens : Math.min(requestedOutput as number, maximumOutputTokens) - const body = Buffer.from(JSON.stringify(normalized), 'utf8') + const body = Buffer.from(canonicalJson(normalized), 'utf8') + if ( + body.byteLength > + REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes + ) { + throw new AgentModelGatewayError( + 'policy', + 'Customized provider request exceeds the configured byte limit' + ) + } const url = providerUrl(profile) - const headers = { ...request.headers } + const headers = mergeModelRequestHeaders( + profile.requestHeaders ?? {}, + request.headers + ) + if (profile.protocol === 'anthropic-messages') { + headers.set('anthropic-version', '2023-06-01') + } + const canonicalHeaders = canonicalModelRequestHeaders(headers) if (profile.protocol === 'anthropic-messages') { - headers['anthropic-version'] = '2023-06-01' if (profile.authentication === 'api-key') { - headers['x-api-key'] = profile.apiKey! + headers.set('x-api-key', profile.apiKey!) } } else if (profile.authentication === 'api-key') { - headers.authorization = `Bearer ${profile.apiKey!}` + headers.set('authorization', `Bearer ${profile.apiKey!}`) } return { url, - headers, + headers: Object.fromEntries(headers.entries()), body, - canonicalRequest: JSON.stringify({ + canonicalRequest: canonicalJson({ profileId: profile.profileId, provider: profile.provider, protocol: profile.protocol, @@ -561,11 +593,7 @@ function prepareProviderRequest( url: url.toString(), request: { path: request.path, - headers: Object.fromEntries( - Object.entries(request.headers).sort(([left], [right]) => - left.localeCompare(right) - ) - ), + headers: canonicalHeaders, bodyBase64: body.toString('base64') } }) @@ -586,20 +614,6 @@ function providerUrl(profile: AgentPromptModelProfile): URL { return url } -function pathMatches( - protocol: AgentPromptModelProfile['protocol'], - path: string -): boolean { - if (protocol === 'anthropic-messages') { - return path === '/v1/messages' - } - if (protocol === 'openai-chat-completions') { - return path === '/chat/completions' || path === '/v1/chat/completions' - } - return path === '/responses' || path === '/v1/responses' -} - - function sha256(value: string | Uint8Array): string { return `sha256:${createHash('sha256').update(value).digest('hex')}` } diff --git a/src/agent-daemon/protocol-server.test.ts b/src/agent-daemon/protocol-server.test.ts index 922adc74..de1524e7 100644 --- a/src/agent-daemon/protocol-server.test.ts +++ b/src/agent-daemon/protocol-server.test.ts @@ -497,7 +497,11 @@ describe('AgentProtocolServer connection bounds', () => { 'runtime/completePrompt', 'runtime/getAcpCursors', 'runtime/escalateCancellation', - 'runtime/reconcilePrompt' + 'runtime/reconcilePrompt', + 'runtime/startPrompt', + 'runtime/attachPrompt', + 'runtime/pagePromptTranscript', + 'runtime/ackPromptTranscript' ].map((method) => [method, () => null]) ) const harness = createHarness({ @@ -525,7 +529,7 @@ describe('AgentProtocolServer connection bounds', () => { expect(daemonCapabilitiesSchema.parse(result)).toMatchObject({ capabilities: [ { name: 'agent/control', version: 1, critical: true }, - { name: 'runtime/acp', version: 3, critical: true }, + { name: 'runtime/acp', version: 5, critical: true }, { name: 'runtime/model-bridge', version: 1, diff --git a/src/agent-daemon/protocol-server.ts b/src/agent-daemon/protocol-server.ts index 6ca612fe..b9a3cf54 100644 --- a/src/agent-daemon/protocol-server.ts +++ b/src/agent-daemon/protocol-server.ts @@ -14,6 +14,10 @@ import { jsonRpcNotificationSchema, jsonRpcRequestSchema, positiveAgentSequenceSchema, + RUNTIME_ACP_CAPABILITY_NAME, + RUNTIME_ACP_CAPABILITY_VERSION, + RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME, + RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION, type DaemonCapabilities, type AgentProtocolFailureCategory, type FrameAcknowledgment, @@ -239,15 +243,18 @@ export class AgentProtocolServer { ...(runtimes.length > 0 ? [ { - name: 'runtime/acp', - version: autonomousPromptsImplemented ? 4 : 3, + name: RUNTIME_ACP_CAPABILITY_NAME, + version: autonomousPromptsImplemented + ? RUNTIME_ACP_CAPABILITY_VERSION + : 3, critical: true }, ...(modelBridgeImplemented ? [ { - name: 'runtime/model-bridge', - version: 1, + name: RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME, + version: + RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION, critical: true } ] diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index 84247a36..b3ad34f6 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -965,6 +965,13 @@ describe('ContinueHostAdapter', () => { protocol, authentication, supportsImageInput: true, + requestHeaders: { + 'x-tenant-id': 'continue-tenant' + }, + requestBody: { + temperature: 0.25, + metadata: { source: 'goodbuddy' } + }, ...(authentication === 'api-key' ? { apiKey: 'private-key' } : {}) @@ -1009,7 +1016,16 @@ describe('ContinueHostAdapter', () => { apiBase: 'http://127.0.0.1:11434/v1', model: 'qwen3', useResponsesApi, - capabilities: ['image_input'] + capabilities: ['image_input'], + requestOptions: { + headers: { + 'x-tenant-id': 'continue-tenant' + }, + extraBodyProperties: { + temperature: 0.25, + metadata: { source: 'goodbuddy' } + } + } } ], mcpServers: [ @@ -2035,8 +2051,10 @@ describe('ContinueHostAdapter', () => { temporaryDirectories.push(root) const requestPaths: string[] = [] const requestBodies: unknown[] = [] + const tenantHeaders: Array = [] const server = createServer(async (request, response) => { requestPaths.push(request.url ?? '') + tenantHeaders.push(request.headers['x-tenant-id']) let body = '' for await (const chunk of request) { body += chunk @@ -2081,7 +2099,14 @@ describe('ContinueHostAdapter', () => { baseUrl: `http://127.0.0.1:${address.port}/v1`, modelName: 'probe-model', protocol, - authentication: 'none' + authentication: 'none', + requestHeaders: { + 'x-tenant-id': 'continue-native' + }, + requestBody: { + temperature: 0.25, + metadata: { source: 'goodbuddy' } + } } }) const controller = new AbortController() @@ -2095,6 +2120,13 @@ describe('ContinueHostAdapter', () => { .catch(() => undefined) expect(requestPaths).toContain(expectedPath) expect(requestPaths).not.toContain(unexpectedPath) + expect(tenantHeaders).toContain('continue-native') + expect(requestBodies).toContainEqual( + expect.objectContaining({ + temperature: 0.25, + metadata: { source: 'goodbuddy' } + }) + ) if (protocol === 'openai-chat-completions') { const chatRequest = requestBodies.find( (body): body is { messages: Array<{ role?: unknown }> } => diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index 10f5815e..0dd52459 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -1375,6 +1375,22 @@ export class ContinueHostAdapter { ? '${{ secrets.ANTHROPIC_API_KEY }}' : '${{ secrets.OPENAI_API_KEY }}' } + const requestHeaders = + this.options.modelProfile.requestHeaders ?? {} + const requestBody = this.options.modelProfile.requestBody ?? {} + if ( + Object.keys(requestHeaders).length > 0 || + Object.keys(requestBody).length > 0 + ) { + modelConfig.requestOptions = { + ...(Object.keys(requestHeaders).length > 0 + ? { headers: requestHeaders } + : {}), + ...(Object.keys(requestBody).length > 0 + ? { extraBodyProperties: requestBody } + : {}) + } + } const configured = this.options.configPath.trim() ? await loadContinueConfig(this.options.configPath.trim()) : {} diff --git a/src/main/agent/create-runtime.test.ts b/src/main/agent/create-runtime.test.ts index cfd3aada..fc79b086 100644 --- a/src/main/agent/create-runtime.test.ts +++ b/src/main/agent/create-runtime.test.ts @@ -114,6 +114,8 @@ describe('createAgentRuntime model compatibility', () => { authentication: 'api-key' as const, supportsImageInput: true, imageGenerationQuality: 'auto' as const, + requestHeaders: { 'x-tenant-id': 'harness-tenant' }, + requestBody: { temperature: 0.2 }, apiKey: 'gateway-key' } const deepseekHarnessLauncher = vi @@ -135,8 +137,14 @@ describe('createAgentRuntime model compatibility', () => { available: false }) expect(deepseekHarnessLauncher).toHaveBeenCalledWith( - expect.objectContaining({ supportsImageInput: true }) + expect.objectContaining({ + supportsImageInput: true, + requestHeaders: { 'x-tenant-id': 'harness-tenant' } + }) ) + expect( + deepseekHarnessLauncher.mock.calls[0]?.[0] + ).not.toHaveProperty('requestBody') await runtime.dispose() }) diff --git a/src/main/agent/create-runtime.ts b/src/main/agent/create-runtime.ts index 1ab59805..15f7e7b2 100644 --- a/src/main/agent/create-runtime.ts +++ b/src/main/agent/create-runtime.ts @@ -104,7 +104,9 @@ function resolveContextCompression( >, authentication: summaryProfile.authentication, contextWindowTokens: summaryProfile.contextWindowTokens, - maximumOutputTokens: summaryProfile.maximumOutputTokens + maximumOutputTokens: summaryProfile.maximumOutputTokens, + requestHeaders: summaryProfile.requestHeaders, + requestBody: summaryProfile.requestBody } } : {}) @@ -127,6 +129,8 @@ export function createDefaultModelRuntime( model: settings.modelName, protocol: settings.modelProtocol, authentication: settings.modelAuthentication, + requestHeaders: currentProfile?.requestHeaders, + requestBody: currentProfile?.requestBody, supportsImageInput: settings.supportsImageInput, maximumOutputTokens: currentProfile?.maximumOutputTokens, defaultWorkspace: settings.workspacePath || defaultWorkspace, @@ -149,6 +153,8 @@ export function createModelProfileRuntime( model: profile.modelName, protocol: profile.protocol, authentication: profile.authentication, + requestHeaders: profile.requestHeaders, + requestBody: profile.requestBody, supportsImageInput: profile.supportsImageInput, maximumOutputTokens: profile.maximumOutputTokens, imageGenerationQuality: @@ -211,6 +217,7 @@ export function createAgentRuntime( baseUrl: profile.baseUrl, model: profile.modelName, supportsImageInput: profile.supportsImageInput === true, + requestHeaders: profile.requestHeaders, launch: capabilities.deepseekHarnessLauncher, credentialRefs: { GOODBUDDY_HARNESS_MODEL_API_KEY: profile.apiKey diff --git a/src/main/agent/deepseek-harness-acp-e2e.test.ts b/src/main/agent/deepseek-harness-acp-e2e.test.ts index 68de4a33..ad98ea7f 100644 --- a/src/main/agent/deepseek-harness-acp-e2e.test.ts +++ b/src/main/agent/deepseek-harness-acp-e2e.test.ts @@ -33,6 +33,8 @@ import { import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane' import { DshNpmExtensionInstaller } from './dsh-extension-marketplace' import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol' +import { createOpenAIChatCompletionsUrl } from './openai-endpoint' +import { createModelRequestProbe } from '../../../tests/support/model-request-probe' const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY' const SKILL_CALL_ID = 'e2e-skill-call' @@ -329,6 +331,7 @@ function createInProcessLaunch( provider: 'goodbuddy', model: options.model, supportsImageInput: options.supportsImageInput, + requestHeaders: options.requestHeaders, harnessVersion: '0.1.0-rc.8', credentialRefs: options.credentialRefs, skillPackages: options.skillPackages, @@ -891,6 +894,79 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => { 60_000 ) + it.runIf(liveModelEnabled)( + 'forwards a custom model header through the real DeepSeek Harness provider', + async () => { + if (!liveApiKey) { + throw new Error( + 'GOODBUDDY_DSH_API_KEY is required for live DSH model E2E' + ) + } + const root = await realpath( + await mkdtemp(join(tmpdir(), 'goodbuddy-harness-header-model-')) + ) + const workspace = join(root, 'workspace') + const dshHome = join(root, 'dsh-home') + await Promise.all([mkdir(workspace), mkdir(dshHome)]) + const probe = await createModelRequestProbe({ + upstreamUrl: createOpenAIChatCompletionsUrl(liveBaseUrl), + headerName: 'x-goodbuddy-e2e' + }) + const inProcess = createInProcessLaunch(dshHome) + const runtime = new DeepSeekHarnessRuntime({ + defaultWorkspace: workspace, + baseUrl: probe.baseUrl, + model: liveModel, + requestHeaders: { + 'x-goodbuddy-e2e': 'harness-header-present' + }, + launch: (options) => inProcess.launch(options), + credentialRefs: { + [CREDENTIAL_REF]: liveApiKey + }, + initializationTimeoutMs: 20_000, + promptTimeoutMs: 120_000, + shutdownTimeoutMs: 5_000 + }) + + try { + const events = await collect( + runtime.run( + { + requestId: 'request-live-custom-header', + conversationId: 'live-custom-header', + prompt: + 'Reply with exactly DSH_CUSTOM_HEADER_E2E_OK and nothing else.', + workMode: 'ask' + }, + new AbortController().signal + ) + ) + expect( + events + .flatMap((event) => + event.type === 'text' ? [event.delta] : [] + ) + .join('') + ).toContain('DSH_CUSTOM_HEADER_E2E_OK') + expect(probe.observations).toEqual([ + { + headerValue: 'harness-header-present', + bodyFields: {} + } + ]) + } finally { + await runtime.dispose() + await Promise.allSettled( + inProcess.hosts.map((host) => host.dispose()) + ) + await probe.close() + await rm(root, { recursive: true, force: true }) + } + }, + 180_000 + ) + it.runIf(liveModelEnabled)( 'lets a real model use Main-brokered Web Search and Fetch in Ask', async () => { diff --git a/src/main/agent/deepseek-harness-control-protocol.ts b/src/main/agent/deepseek-harness-control-protocol.ts index 0a438452..cb29d804 100644 --- a/src/main/agent/deepseek-harness-control-protocol.ts +++ b/src/main/agent/deepseek-harness-control-protocol.ts @@ -1,6 +1,9 @@ import { isAbsolute } from 'node:path' import { z } from 'zod' import { isDeepSeekHarnessCompatibleBaseUrl } from '../../shared/deepseek-harness-compatibility' +import { + modelRequestHeadersSchema +} from '../../shared/model-request-customization' import { runtimeExtensionConfigurationSchema, runtimeExtensionIdSchema @@ -9,7 +12,7 @@ import { bundledDeepSeekHarnessVersion } from './bundled-runtimes' export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL = 'goodbuddy.deepseek-harness.control' -export const DEEPSEEK_HARNESS_CONTROL_VERSION = 2 +export const DEEPSEEK_HARNESS_CONTROL_VERSION = 3 export const DEEPSEEK_HARNESS_HOST_VERSION = bundledDeepSeekHarnessVersion export const DEEPSEEK_HARNESS_CREDENTIAL_REF = @@ -84,6 +87,7 @@ export const controlledHarnessHostConfigSchema = z provider: z.literal('goodbuddy'), model: z.string().min(1).max(128), supportsImageInput: z.boolean(), + requestHeaders: modelRequestHeadersSchema, harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION), credentialRefs: z .tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)]) diff --git a/src/main/agent/deepseek-harness-runtime.test.ts b/src/main/agent/deepseek-harness-runtime.test.ts index 0a4898a8..ca74d216 100644 --- a/src/main/agent/deepseek-harness-runtime.test.ts +++ b/src/main/agent/deepseek-harness-runtime.test.ts @@ -48,6 +48,7 @@ function setup( maxEventCharacters?: number maxRequestOutputCharacters?: number supportsImageInput?: boolean + requestHeaders?: Record advertisedImageInput?: boolean initializationTimeoutMs?: number useDefaultInitializationTimeout?: boolean @@ -247,6 +248,7 @@ function setup( baseUrl: 'https://api.deepseek.com', model: 'deepseek-test', supportsImageInput: options.supportsImageInput, + requestHeaders: options.requestHeaders, launch, loadAcpSdk: async () => sdk, ...(options.useDefaultInitializationTimeout @@ -639,7 +641,9 @@ describe('DeepSeekHarnessRuntime', () => { }) it('uses ACP stdio, maps conversations to sessions, and streams text', async () => { - const harness = setup() + const harness = setup({ + requestHeaders: { 'x-tenant-id': 'harness-tenant' } + }) const first = collect( harness.runtime.run( request('one'), @@ -678,6 +682,7 @@ describe('DeepSeekHarnessRuntime', () => { baseUrl: 'https://api.deepseek.com', model: 'deepseek-test', supportsImageInput: false, + requestHeaders: { 'x-tenant-id': 'harness-tenant' }, credentialRefs: [], skillPackages: [], extensionPackages: [] diff --git a/src/main/agent/deepseek-harness-runtime.ts b/src/main/agent/deepseek-harness-runtime.ts index 3793467e..baaa8ee2 100644 --- a/src/main/agent/deepseek-harness-runtime.ts +++ b/src/main/agent/deepseek-harness-runtime.ts @@ -1,5 +1,6 @@ import type { AgentRuntimeStatus, + ModelRequestHeaders, RuntimeNativeSnapshot, RuntimeNativeTool } from '../../shared/contracts' @@ -173,6 +174,7 @@ export type DeepSeekHarnessLaunchOptions = { baseUrl: string model: string supportsImageInput: boolean + requestHeaders?: ModelRequestHeaders credentialRefs: readonly string[] skillPackages: readonly RuntimeSkillPackage[] extensionPackages: readonly ControlledHarnessExtensionPackage[] @@ -183,6 +185,7 @@ export type DeepSeekHarnessRuntimeOptions = { baseUrl: string model: string supportsImageInput?: boolean + requestHeaders?: ModelRequestHeaders launch: ( options: DeepSeekHarnessLaunchOptions ) => Promise @@ -821,6 +824,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { model: this.options.model, supportsImageInput: this.options.supportsImageInput === true, + requestHeaders: this.options.requestHeaders ?? {}, credentialRefs: Object.keys( this.options.credentialRefs ?? {} ), diff --git a/src/main/agent/deepseek-harness-utility-launcher.test.ts b/src/main/agent/deepseek-harness-utility-launcher.test.ts index 493d2c6c..51942cf7 100644 --- a/src/main/agent/deepseek-harness-utility-launcher.test.ts +++ b/src/main/agent/deepseek-harness-utility-launcher.test.ts @@ -54,6 +54,7 @@ async function fixture() { baseUrl: 'http://gateway.example/openai/v1?api-version=1', model: 'qwen-plus', supportsImageInput: false, + requestHeaders: { 'x-tenant-id': 'harness-tenant' }, credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF], skillPackages: [], extensionPackages: [] @@ -63,6 +64,7 @@ async function fixture() { describe('DeepSeek Harness utility launcher', () => { it('accepts only strict control messages and secret-free config', () => { + expect(DEEPSEEK_HARNESS_CONTROL_VERSION).toBe(3) expect( parseHarnessControlMessage({ protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL, @@ -106,6 +108,9 @@ describe('DeepSeek Harness utility launcher', () => { baseUrl: 'http://gateway.example/openai/v1?api-version=1', model: 'qwen-plus', supportsImageInput: false, + requestHeaders: { + 'x-tenant-id': 'harness-tenant' + }, credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF] } }) diff --git a/src/main/agent/deepseek-harness-utility-launcher.ts b/src/main/agent/deepseek-harness-utility-launcher.ts index 41c3ec15..55104a0e 100644 --- a/src/main/agent/deepseek-harness-utility-launcher.ts +++ b/src/main/agent/deepseek-harness-utility-launcher.ts @@ -288,6 +288,7 @@ export function createDeepSeekHarnessUtilityLauncher( provider: 'goodbuddy', model: options.model, supportsImageInput: options.supportsImageInput, + requestHeaders: options.requestHeaders ?? {}, harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION, credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF], skillPackages: canonicalSkillPackages, diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index c52b5c57..86e98605 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -464,6 +464,59 @@ describe('ModelAgentRuntime', () => { expect(fetcher).toHaveBeenCalledOnce() }) + it('merges custom headers and body fields into a direct model request without overriding runtime fields', async () => { + const fetcher = vi.fn(async (_input, init) => { + const body = JSON.parse(init?.body as string) as { + messages: Array<{ content: string }> + } + const marker = + /GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec( + body.messages[0]?.content ?? '' + )?.[0] + return Response.json({ + choices: [ + { + message: { + role: 'assistant', + content: marker + } + } + ] + }) + }) + const runtime = new ModelAgentRuntime({ + apiKey: 'runtime-key', + baseUrl: 'https://model.example/v1', + model: 'runtime-model', + protocol: 'openai-chat-completions', + authentication: 'api-key', + requestHeaders: { + 'x-tenant-id': 'tenant-a', + authorization: 'Bearer custom-key' + }, + requestBody: { + temperature: 0.2, + model: 'custom-model', + stream: true + }, + fetcher + }) + + await expect(runtime.testConnection()).resolves.toMatchObject({ + available: true + }) + const init = fetcher.mock.calls[0]?.[1] + const headers = new Headers(init?.headers) + const body = JSON.parse(String(init?.body)) as Record + expect(headers.get('x-tenant-id')).toBe('tenant-a') + expect(headers.get('authorization')).toBe('Bearer runtime-key') + expect(body).toMatchObject({ + model: 'runtime-model', + stream: false, + temperature: 0.2 + }) + }) + it('uses the Anthropic messages endpoint and streams text deltas', async () => { const fetcher = vi.fn(async () => { return new Response(createEventStream('真实模型回答', '先分析问题'), { @@ -569,6 +622,8 @@ describe('ModelAgentRuntime', () => { model: 'sonnet-5', protocol: 'anthropic-messages', authentication: 'api-key', + requestHeaders: { 'x-model-route': 'summary-test' }, + requestBody: { temperature: 0.1 }, fetcher, contextCompression: { settings: { @@ -622,6 +677,14 @@ describe('ModelAgentRuntime', () => { } expect(fetcher).toHaveBeenCalledTimes(2) + for (const [, init] of fetcher.mock.calls) { + expect(new Headers(init?.headers).get('x-model-route')).toBe( + 'summary-test' + ) + expect(JSON.parse(String(init?.body))).toMatchObject({ + temperature: 0.1 + }) + } const summaryBody = JSON.parse( fetcher.mock.calls[0]![1]!.body as string ) as { max_tokens: number; system: string; messages: unknown[] } @@ -5168,6 +5231,11 @@ describe('ModelAgentRuntime', () => { protocol: 'openai-images-generations', authentication: 'api-key', imageGenerationQuality: 'medium', + requestHeaders: { 'x-image-route': 'internal' }, + requestBody: { + background: 'transparent', + quality: 'high' + }, fetcher }) @@ -5185,8 +5253,14 @@ describe('ModelAgentRuntime', () => { model: 'gpt-image-2', n: 1, quality: 'medium', + background: 'transparent', response_format: 'b64_json' }) + expect( + new Headers(fetcher.mock.calls[0]?.[1]?.headers).get( + 'x-image-route' + ) + ).toBe('internal') }) it('generates a bounded image through the BigToken-compatible endpoint', async () => { diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index 913a785c..3683ddcd 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -6,9 +6,15 @@ import type { ImageGenerationQuality, ModelAuthentication, ModelProtocol, + ModelRequestBody, + ModelRequestHeaders, RuntimeConversationCompactInput } from '../../shared/contracts' -import { defaultAnthropicMaximumOutputTokens } from '../../shared/contracts' +import { + defaultAnthropicMaximumOutputTokens, + mergeModelRequestBody, + mergeModelRequestHeaders +} from '../../shared/contracts' import type { ResolvedMcpServer } from '../capabilities/capability-service' import type { BrowserToolService } from '../browser/browser-model-tools' import type { LaunchEnvironmentProvider } from '../local-tool-environment' @@ -242,6 +248,8 @@ export type ModelRuntimeOptions = { model: string protocol: ModelProtocol authentication: ModelAuthentication + requestHeaders?: ModelRequestHeaders + requestBody?: ModelRequestBody supportsImageInput?: boolean imageGenerationQuality?: ImageGenerationQuality skillInstructions?: string @@ -265,6 +273,8 @@ export type ModelRuntimeOptions = { authentication: ModelAuthentication contextWindowTokens?: number maximumOutputTokens?: number + requestHeaders?: ModelRequestHeaders + requestBody?: ModelRequestBody } } maximumOutputTokens?: number @@ -1762,35 +1772,47 @@ export class ModelAgentRuntime implements AgentRuntime { } private getHeaders(): Record { - const headers: Record = { - 'content-type': 'application/json' - } + const headers = mergeModelRequestHeaders( + this.options.requestHeaders ?? {}, + { + 'content-type': 'application/json' + } + ) if ( this.options.authentication === 'api-key' && this.options.apiKey ) { if (this.options.protocol === 'anthropic-messages') { - headers['anthropic-version'] = '2023-06-01' - headers['x-api-key'] = this.options.apiKey + headers.set('anthropic-version', '2023-06-01') + headers.set('x-api-key', this.options.apiKey) } else { - headers.authorization = `Bearer ${this.options.apiKey}` + headers.set('authorization', `Bearer ${this.options.apiKey}`) } } else if (this.options.protocol === 'anthropic-messages') { - headers['anthropic-version'] = '2023-06-01' + headers.set('anthropic-version', '2023-06-01') } - return headers + return Object.fromEntries(headers.entries()) + } + + private createRequestBody( + runtimeBody: Record + ): Record { + return mergeModelRequestBody( + this.options.requestBody ?? {}, + runtimeBody + ) } private createImageGenerationRequest( prompt: string ): Record { - return { + return this.createRequestBody({ model: this.options.model, prompt: prompt.slice(0, 100_000), n: 1, quality: this.options.imageGenerationQuality ?? 'auto', response_format: 'b64_json' - } + }) } private async fetchWithTimeout( @@ -1921,19 +1943,21 @@ export class ModelAgentRuntime implements AgentRuntime { body: JSON.stringify( imageGeneration ? this.createImageGenerationRequest(prompt) - : this.options.protocol === 'openai-responses' - ? { - model: this.options.model, - max_output_tokens: 64, - stream: false, - input: prompt - } - : { - model: this.options.model, - max_tokens: 64, - stream: false, - messages: [{ role: 'user', content: prompt }] - } + : this.createRequestBody( + this.options.protocol === 'openai-responses' + ? { + model: this.options.model, + max_output_tokens: 64, + stream: false, + input: prompt + } + : { + model: this.options.model, + max_tokens: 64, + stream: false, + messages: [{ role: 'user', content: prompt }] + } + ) ) }) const responseText = await readBoundedResponseText(response, { @@ -2121,7 +2145,9 @@ export class ModelAgentRuntime implements AgentRuntime { 'openai-images-generations' >, authentication: this.options.authentication, - maximumOutputTokens: this.options.maximumOutputTokens + maximumOutputTokens: this.options.maximumOutputTokens, + requestHeaders: this.options.requestHeaders, + requestBody: this.options.requestBody } const summaryRuntime = new ModelAgentRuntime({ ...summaryModel, @@ -2775,38 +2801,40 @@ export class ModelAgentRuntime implements AgentRuntime { } })) const body = JSON.stringify( - responses - ? { - model: this.options.model, - stream: true, - instructions: system, - input: messages, - ...(providerTools.length > 0 - ? { tools: providerTools } - : {}) - } - : anthropic + this.createRequestBody( + responses ? { model: this.options.model, - max_tokens: this.anthropicProtocolMaximumTokens, stream: true, - system, - messages, - ...(providerTools.length > 0 - ? { tools: providerTools } - : {}) - } - : { - model: this.options.model, - stream: true, - stream_options: { - include_usage: true - }, - messages, + instructions: system, + input: messages, ...(providerTools.length > 0 ? { tools: providerTools } : {}) } + : anthropic + ? { + model: this.options.model, + max_tokens: this.anthropicProtocolMaximumTokens, + stream: true, + system, + messages, + ...(providerTools.length > 0 + ? { tools: providerTools } + : {}) + } + : { + model: this.options.model, + stream: true, + stream_options: { + include_usage: true + }, + messages, + ...(providerTools.length > 0 + ? { tools: providerTools } + : {}) + } + ) ) if (Buffer.byteLength(body) > 2 * 1024 * 1024) { throw new Error('模型工具请求上下文超过 2MB 安全限制') @@ -3059,29 +3087,31 @@ export class ModelAgentRuntime implements AgentRuntime { method: 'POST', headers: this.getHeaders(), body: JSON.stringify( - responses - ? { - model: this.options.model, - stream: true, - instructions: system, - input: messages - } - : anthropic + this.createRequestBody( + responses ? { model: this.options.model, - max_tokens: this.anthropicProtocolMaximumTokens, stream: true, - system, - messages - } - : { - model: this.options.model, - stream: true, - stream_options: { - include_usage: true - }, - messages + instructions: system, + input: messages } + : anthropic + ? { + model: this.options.model, + max_tokens: this.anthropicProtocolMaximumTokens, + stream: true, + system, + messages + } + : { + model: this.options.model, + stream: true, + stream_options: { + include_usage: true + }, + messages + } + ) ) }, signal diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index eeb47efb..044a2cbf 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -678,9 +678,20 @@ describe('OpenCodeRuntime embedded launcher', () => { join(tmpdir(), 'goodbuddy-opencode-routing-') ) const requestPaths: string[] = [] - const server = createServer((request, response) => { + const tenantHeaders: Array = [] + const requestBodies: Array> = [] + const server = createServer(async (request, response) => { requestPaths.push(request.url ?? '') - request.resume() + tenantHeaders.push(request.headers['x-tenant-id']) + let body = '' + for await (const chunk of request) { + body += String(chunk) + } + if (body) { + requestBodies.push( + JSON.parse(body) as Record + ) + } response.writeHead(400, { 'content-type': 'application/json' }) @@ -730,7 +741,13 @@ describe('OpenCodeRuntime embedded launcher', () => { modelName: 'probe-model', protocol, authentication: 'api-key', - apiKey: 'local-probe-key' + apiKey: 'local-probe-key', + requestHeaders: { + 'x-tenant-id': 'opencode-native' + }, + requestBody: { + goodbuddy_unsupported_probe: 'must-not-send' + } } }), { startupTimeoutMs: 20_000 } @@ -765,6 +782,12 @@ describe('OpenCodeRuntime embedded launcher', () => { } expect(requestPaths).toContain(expectedPath) expect(requestPaths).not.toContain(unexpectedPath) + expect(tenantHeaders).toContain('opencode-native') + expect(requestBodies).not.toContainEqual( + expect.objectContaining({ + goodbuddy_unsupported_probe: 'must-not-send' + }) + ) } finally { clearTimeout(timeout) await runtime.dispose() @@ -820,6 +843,12 @@ describe('OpenCodeRuntime embedded launcher', () => { modelName: 'custom-model', protocol, authentication, + requestHeaders: { + 'x-tenant-id': 'opencode-tenant' + }, + requestBody: { + temperature: 0.25 + }, ...(authentication === 'api-key' ? { apiKey: 'private-key' } : {}) @@ -854,7 +883,10 @@ describe('OpenCodeRuntime embedded launcher', () => { expect(config.provider?.[providerId]).toMatchObject({ npm: providerPackage, options: { - baseURL: 'https://model.example/v1' + baseURL: 'https://model.example/v1', + headers: { + 'x-tenant-id': 'opencode-tenant' + } }, models: { 'custom-model': { @@ -873,6 +905,7 @@ describe('OpenCodeRuntime embedded launcher', () => { config.provider?.[providerId]?.options ).not.toHaveProperty('apiKey') } + expect(JSON.stringify(config)).not.toContain('temperature') await runtime.dispose() } ) diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 61e3f24c..885be13d 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -135,6 +135,7 @@ type OpenCodeProviderConfig = { options: { apiKey?: string baseURL: string + headers?: Record } models: Record< string, @@ -214,8 +215,13 @@ function createOpenCodeProviderConfig( const options: { apiKey?: string baseURL: string + headers?: Record } = { - baseURL: provider.baseURL + baseURL: provider.baseURL, + ...(profile.requestHeaders && + Object.keys(profile.requestHeaders).length > 0 + ? { headers: profile.requestHeaders } + : {}) } if (profile.authentication === 'api-key' && profile.apiKey) { options.apiKey = profile.apiKey diff --git a/src/main/agent/remote-model-gateway.test.ts b/src/main/agent/remote-model-gateway.test.ts index 171db7e0..cd2c23f9 100644 --- a/src/main/agent/remote-model-gateway.test.ts +++ b/src/main/agent/remote-model-gateway.test.ts @@ -154,6 +154,59 @@ describe('RemoteModelGateway', () => { expect(JSON.stringify(completed)).not.toContain('"messages"') }) + it('applies prompt-scoped custom headers and body fields before dispatch', async () => { + const store = await createStore() + const customizedProfile: ResolvedModelProfile = { + ...anthropicProfile, + requestHeaders: { + 'x-tenant-id': 'remote-tenant' + }, + requestBody: { + temperature: 0.2, + metadata: { source: 'goodbuddy' } + } + } + const customizedRequest = { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ + model: 'claude-test', + messages: [], + temperature: 0.8 + }) + ).toString('base64') + } + const fetcher = vi.fn(async (_url, init) => { + expect(new Headers(init?.headers).get('x-tenant-id')).toBe( + 'remote-tenant' + ) + expect(JSON.parse(String(init?.body))).toEqual({ + messages: [], + metadata: { source: 'goodbuddy' }, + model: 'claude-test', + temperature: 0.8 + }) + return new Response('{}') + }) + const gateway = new RemoteModelGateway({ store, fetcher }) + + await gateway.dispatch( + { + ...context, + modelProfile: customizedProfile, + modelProfileDigest: + createResolvedModelProfileDigest(customizedProfile) + }, + customizedRequest, + new AbortController().signal + ) + + expect(fetcher).toHaveBeenCalledOnce() + expect( + createResolvedModelProfileDigest(customizedProfile) + ).not.toBe(createResolvedModelProfileDigest(anthropicProfile)) + }) + it('does not clamp Runtime-selected provider output parameters', async () => { const store = await createStore() let sentBody: Record | undefined diff --git a/src/main/agent/remote-model-gateway.ts b/src/main/agent/remote-model-gateway.ts index 958a091d..4197c30e 100644 --- a/src/main/agent/remote-model-gateway.ts +++ b/src/main/agent/remote-model-gateway.ts @@ -28,6 +28,13 @@ import { assertTextModelRequestPolicy, ModelRequestPolicyError } from '../../shared/model-request-policy' +import { modelBridgePathMatchesProtocol } from '../../shared/model-bridge-contracts' +import { canonicalJson } from '../../shared/agent-protocol/canonical' +import { + canonicalModelRequestHeaders, + mergeModelRequestBody, + mergeModelRequestHeaders +} from '../../shared/model-request-customization' const DEFAULT_TIMEOUT_MS = 60_000 const RESPONSE_HEADER_NAMES = [ @@ -302,6 +309,24 @@ function prepareDispatch( profile, Buffer.from(parsedRequest.bodyBase64, 'base64') ) + if ( + body.byteLength > + REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes + ) { + throw new RemoteModelGatewayError( + 'request-too-large', + 'Customized remote model request is too large' + ) + } + const providerHeaders = mergeModelRequestHeaders( + profile.requestHeaders ?? {}, + parsedRequest.headers + ) + if (profile.protocol === 'anthropic-messages') { + providerHeaders.set('anthropic-version', '2023-06-01') + } + const canonicalHeaders = + canonicalModelRequestHeaders(providerHeaders) const normalizedBodyBase64 = body.toString('base64') const canonicalRequest = JSON.stringify({ profileId: profile.id, @@ -312,11 +337,7 @@ function prepareDispatch( request: { method: parsedRequest.method, path: parsedRequest.path, - headers: Object.fromEntries( - Object.entries(parsedRequest.headers).sort(([left], [right]) => - left.localeCompare(right) - ) - ), + headers: canonicalHeaders, bodyBase64: normalizedBodyBase64 } }) @@ -338,16 +359,12 @@ function prepareDispatch( protocol: profile.protocol }) - const headers: Record = { - ...parsedRequest.headers - } if (profile.protocol === 'anthropic-messages') { - headers['anthropic-version'] = '2023-06-01' if (profile.authentication === 'api-key') { - headers['x-api-key'] = profile.apiKey! + providerHeaders.set('x-api-key', profile.apiKey!) } } else if (profile.authentication === 'api-key') { - headers.authorization = `Bearer ${profile.apiKey!}` + providerHeaders.set('authorization', `Bearer ${profile.apiKey!}`) } return { @@ -355,7 +372,7 @@ function prepareDispatch( requestDigest, modelProfileDigest, url, - headers, + headers: Object.fromEntries(providerHeaders.entries()), body } } @@ -381,7 +398,7 @@ export function createResolvedModelProfileDigest( profile: ResolvedModelProfile ): string { return sha256( - JSON.stringify({ + canonicalJson({ id: profile.id, name: profile.name, baseUrl: profile.baseUrl, @@ -391,7 +408,13 @@ export function createResolvedModelProfileDigest( supportsImageInput: profile.supportsImageInput === true, contextWindowTokens: profile.contextWindowTokens ?? null, maximumOutputTokens: profile.maximumOutputTokens ?? null, - imageGenerationQuality: profile.imageGenerationQuality ?? null + imageGenerationQuality: profile.imageGenerationQuality ?? null, + requestHeaders: Object.fromEntries( + Object.entries(profile.requestHeaders ?? {}).map( + ([name, value]) => [name.toLowerCase(), value] + ) + ), + requestBody: profile.requestBody ?? {} }) ) } @@ -415,6 +438,10 @@ function normalizeProviderRequestBody( ) { throw requestPolicyError() } + const request = mergeModelRequestBody( + profile.requestBody ?? {}, + value as Record + ) try { assertTextModelRequestPolicy( { @@ -422,7 +449,7 @@ function normalizeProviderRequestBody( model: profile.modelName, supportsImageInput: profile.supportsImageInput === true }, - value + request ) } catch (error) { if (!(error instanceof ModelRequestPolicyError)) { @@ -430,8 +457,7 @@ function normalizeProviderRequestBody( } throw requestPolicyError() } - const request = { ...value } - return Buffer.from(JSON.stringify(request), 'utf8') + return Buffer.from(canonicalJson(request), 'utf8') } function requestPolicyError(): RemoteModelGatewayError { @@ -445,25 +471,19 @@ function providerUrl( profile: ResolvedModelProfile, path: string ): URL { + if ( + profile.protocol === 'openai-images-generations' || + !modelBridgePathMatchesProtocol(profile.protocol, path) + ) { + throw protocolPathError() + } if (profile.protocol === 'anthropic-messages') { - if (path !== '/v1/messages') { - throw protocolPathError() - } return createAnthropicMessagesUrl(profile.baseUrl) } if (profile.protocol === 'openai-chat-completions') { - if ( - path !== '/chat/completions' && - path !== '/v1/chat/completions' - ) { - throw protocolPathError() - } return createOpenAIChatCompletionsUrl(profile.baseUrl) } if (profile.protocol === 'openai-responses') { - if (path !== '/responses' && path !== '/v1/responses') { - throw protocolPathError() - } return createOpenAIResponsesUrl(profile.baseUrl) } throw protocolPathError() diff --git a/src/main/agent/runtime-e2e.manual.test.ts b/src/main/agent/runtime-e2e.manual.test.ts index 210012be..7645b767 100644 --- a/src/main/agent/runtime-e2e.manual.test.ts +++ b/src/main/agent/runtime-e2e.manual.test.ts @@ -36,6 +36,12 @@ import { } from '../../shared/goodbuddy-config-tools' import { KnowledgeMcpGateway } from './knowledge-mcp-gateway' import { SubagentScheduler } from '../assistant/subagent-scheduler' +import { createAnthropicMessagesUrl } from './anthropic-endpoint' +import { + createOpenAIChatCompletionsUrl, + createOpenAIResponsesUrl +} from './openai-endpoint' +import { createModelRequestProbe } from '../../../tests/support/model-request-probe' const enabled = process.env.GOODBUDDY_RUN_RUNTIME_E2E === '1' const apiKey = @@ -374,6 +380,65 @@ describe.runIf(enabled)('runtime end-to-end', () => { 120_000 ) + it( + 'adds configured headers and body fields to one real direct model request', + async () => { + const upstreamUrl = + protocol === 'anthropic-messages' + ? createAnthropicMessagesUrl(baseUrl) + : protocol === 'openai-responses' + ? createOpenAIResponsesUrl(baseUrl) + : createOpenAIChatCompletionsUrl(baseUrl) + const probe = await createModelRequestProbe({ + upstreamUrl, + headerName: 'x-goodbuddy-e2e', + bodyFieldNames: ['goodbuddy_e2e_probe'], + removeBodyFieldNames: ['goodbuddy_e2e_probe'] + }) + const runtime = new ModelAgentRuntime({ + apiKey, + baseUrl: probe.baseUrl, + model: modelName, + protocol, + authentication: 'api-key', + requestHeaders: { + 'x-goodbuddy-e2e': 'header-present' + }, + requestBody: { + goodbuddy_e2e_probe: 'body-present' + } + }) + + try { + const output = await collectText( + runtime.run( + { + requestId: crypto.randomUUID(), + conversationId: crypto.randomUUID(), + workMode: 'ask', + prompt: + 'Return exactly this text and nothing else: CUSTOM_REQUEST_E2E_OK' + }, + new AbortController().signal + ) + ) + expect(output).toContain('CUSTOM_REQUEST_E2E_OK') + expect(probe.observations).toEqual([ + { + headerValue: 'header-present', + bodyFields: { + goodbuddy_e2e_probe: 'body-present' + } + } + ]) + } finally { + await runtime.dispose() + await probe.close() + } + }, + 180_000 + ) + it( 'uses real direct-model process execution and a programming Subagent to repair and verify code', async () => { diff --git a/src/main/deepseek-harness-host.ts b/src/main/deepseek-harness-host.ts index deaa10df..1ea70e5d 100644 --- a/src/main/deepseek-harness-host.ts +++ b/src/main/deepseek-harness-host.ts @@ -34,6 +34,7 @@ import { } from './agent/deepseek-harness-extension-loader' import type { Stream } from '@agentclientprotocol/sdk' import { isDeepSeekHarnessCompatibleBaseUrl } from '../shared/deepseek-harness-compatibility' +import type { ModelRequestHeaders } from '../shared/model-request-customization' import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './agent/deepseek-harness-control-protocol' const MAX_DIAGNOSTIC_BYTES = 64 * 1024 @@ -45,6 +46,7 @@ export type ControlledHarnessHostConfig = Omit< workspace: string baseUrl: string api: 'openai-completions' + requestHeaders?: ModelRequestHeaders maxFrameBytes?: number stream?: Stream dshHome: string @@ -249,6 +251,7 @@ export async function startControlledDeepSeekHarnessHost( apiKeyEnv: config.credentialRefs[0], api: config.api, baseURL: config.baseUrl, + headers: config.requestHeaders ?? {}, models: [ { id: config.model, diff --git a/src/main/remote-agent/managed-model-bridge.test.ts b/src/main/remote-agent/managed-model-bridge.test.ts index 57f5d5b7..4736bf05 100644 --- a/src/main/remote-agent/managed-model-bridge.test.ts +++ b/src/main/remote-agent/managed-model-bridge.test.ts @@ -11,6 +11,12 @@ const profile: ResolvedModelProfile = { authentication: 'api-key', supportsImageInput: true, maximumOutputTokens: 8_192, + requestHeaders: { + 'x-tenant-id': 'remote-tenant' + }, + requestBody: { + temperature: 0.2 + }, apiKey: 'prompt-scoped-secret' } @@ -29,6 +35,12 @@ describe('managed Agent prompt model setup', () => { protocol: 'openai-responses', authentication: 'api-key', apiKey: 'prompt-scoped-secret', + requestHeaders: { + 'x-tenant-id': 'remote-tenant' + }, + requestBody: { + temperature: 0.2 + }, capabilities: { imageInput: true }, limits: { maximumOutputTokens: 8_192, diff --git a/src/main/remote-agent/managed-model-bridge.ts b/src/main/remote-agent/managed-model-bridge.ts index 1a215e38..15f77340 100644 --- a/src/main/remote-agent/managed-model-bridge.ts +++ b/src/main/remote-agent/managed-model-bridge.ts @@ -55,6 +55,14 @@ export function createManagedModelBridge(options: { ...(selected.apiKey === undefined ? {} : { apiKey: selected.apiKey }), + ...(selected.requestHeaders && + Object.keys(selected.requestHeaders).length > 0 + ? { requestHeaders: selected.requestHeaders } + : {}), + ...(selected.requestBody && + Object.keys(selected.requestBody).length > 0 + ? { requestBody: selected.requestBody } + : {}), capabilities: { imageInput: selected.supportsImageInput === true }, diff --git a/src/main/remote-agent/managed-remote-acp-runtime.test.ts b/src/main/remote-agent/managed-remote-acp-runtime.test.ts index 09b2db47..9a87cbb1 100644 --- a/src/main/remote-agent/managed-remote-acp-runtime.test.ts +++ b/src/main/remote-agent/managed-remote-acp-runtime.test.ts @@ -297,7 +297,7 @@ describe('createManagedRemoteAcpRuntime', () => { fixture.options.agentServices.connectionManager.acquire ).toHaveBeenCalledWith('host-1', expect.objectContaining({ requiredCapabilities: [ - { name: 'runtime/acp', exactVersion: 4, critical: true } + { name: 'runtime/acp', exactVersion: 5, critical: true } ] })) @@ -514,7 +514,7 @@ function capabilities() { return { generation: 7, capabilities: [ - { name: 'runtime/acp', version: 4, critical: true } + { name: 'runtime/acp', version: 5, critical: true } ], runtimes: [ { diff --git a/src/main/remote-agent/managed-remote-acp-runtime.ts b/src/main/remote-agent/managed-remote-acp-runtime.ts index 1840a3a6..02baac5a 100644 --- a/src/main/remote-agent/managed-remote-acp-runtime.ts +++ b/src/main/remote-agent/managed-remote-acp-runtime.ts @@ -26,10 +26,14 @@ import type { RemoteRuntimeInstallationIdentity, RemoteRuntimeInstallationManager } from './remote-runtime-installation-manager' +import { + RUNTIME_ACP_CAPABILITY_NAME, + RUNTIME_ACP_CAPABILITY_VERSION +} from '../../shared/agent-protocol' const RUNTIME_ACP_CAPABILITY = { - name: 'runtime/acp', - exactVersion: 4, + name: RUNTIME_ACP_CAPABILITY_NAME, + exactVersion: RUNTIME_ACP_CAPABILITY_VERSION, critical: true } as const diff --git a/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts b/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts index cb41b3be..01a54fdf 100644 --- a/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts +++ b/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts @@ -40,7 +40,7 @@ function advertisedCapabilities(): DaemonCapabilities { generation: 2, capabilities: [ { name: 'workspace/read', version: 1, critical: true }, - { name: 'runtime/acp', version: 4, critical: true }, + { name: 'runtime/acp', version: 5, critical: true }, { name: 'runtime/model-bridge', version: 1, @@ -250,7 +250,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => { ...advertisedCapabilities(), capabilities: [ { name: 'workspace/read', version: 1, critical: true }, - { name: 'runtime/acp', version: 4, critical: true } + { name: 'runtime/acp', version: 5, critical: true } ] } ], @@ -260,7 +260,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => { ...advertisedCapabilities(), capabilities: [ { name: 'workspace/read', version: 1, critical: true }, - { name: 'runtime/acp', version: 4, critical: true }, + { name: 'runtime/acp', version: 5, critical: true }, { name: 'runtime/model-bridge', version: 3, @@ -276,7 +276,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => { capabilities: [ { name: 'runtime/acp', - version: 4, + version: 5, critical: false } ] diff --git a/src/main/remote-agent/managed-remote-project-runtime-validator.ts b/src/main/remote-agent/managed-remote-project-runtime-validator.ts index ce773956..d683f8dd 100644 --- a/src/main/remote-agent/managed-remote-project-runtime-validator.ts +++ b/src/main/remote-agent/managed-remote-project-runtime-validator.ts @@ -10,11 +10,12 @@ import type { RemoteRuntimeInstallationIdentity, RemoteRuntimeInstallationManager } from './remote-runtime-installation-manager' - -const RUNTIME_ACP_CAPABILITY = 'runtime/acp' -const RUNTIME_ACP_CAPABILITY_VERSION = 4 -const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge' -const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1 +import { + RUNTIME_ACP_CAPABILITY_NAME, + RUNTIME_ACP_CAPABILITY_VERSION, + RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME, + RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION +} from '../../shared/agent-protocol' export class ManagedRemoteProjectRuntimeValidator implements RemoteProjectRuntimeValidator @@ -178,11 +179,11 @@ function assertRuntimeCapability( installation: RemoteRuntimeInstallationIdentity ): void { const acp = capabilities.capabilities.find( - (capability) => capability.name === RUNTIME_ACP_CAPABILITY + (capability) => capability.name === RUNTIME_ACP_CAPABILITY_NAME ) const modelBridge = capabilities.capabilities.find( (capability) => - capability.name === RUNTIME_MODEL_BRIDGE_CAPABILITY + capability.name === RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME ) const runtimes = capabilities.runtimes.filter( (runtime) => runtime.runtimeId === 'opencode' diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.test.ts b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts index bc5d6147..3af10552 100644 --- a/src/main/remote-agent/protocol-remote-runtime-channel.test.ts +++ b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts @@ -320,7 +320,7 @@ function validCapabilities(): DaemonCapabilities { return { generation: 11, capabilities: [ - { name: 'runtime/acp', version: 4, critical: true } + { name: 'runtime/acp', version: 5, critical: true } ], runtimes: [ { diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.ts b/src/main/remote-agent/protocol-remote-runtime-channel.ts index 7234c7cd..14535117 100644 --- a/src/main/remote-agent/protocol-remote-runtime-channel.ts +++ b/src/main/remote-agent/protocol-remote-runtime-channel.ts @@ -16,7 +16,11 @@ import { acpReplayChannelResultSchema, acpReconcilePromptRequestSchema as promptReconciliationRequestSchema, acpReconcilePromptResultSchema as promptReconciliationResultSchema, - acpResumeChannelResultSchema + acpResumeChannelResultSchema, + RUNTIME_ACP_CAPABILITY_NAME, + RUNTIME_ACP_CAPABILITY_VERSION, + RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME, + RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION } from '../../shared/agent-protocol' import { AgentFrameError } from '../../shared/agent-protocol/frame' import { @@ -75,10 +79,6 @@ import type { MainModelBridgePoison } from './main-model-bridge-dispatcher' -const RUNTIME_ACP_CAPABILITY = 'runtime/acp' -const RUNTIME_ACP_CAPABILITY_VERSION = 4 -const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge' -const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1 const DEFAULT_CONTROL_TIMEOUT_MS = 15_000 const DEFAULT_MAXIMUM_PENDING_CONTROL_REQUESTS = 32 const UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS = Date.parse( @@ -1817,13 +1817,14 @@ function assertRuntimeCapability( requireModelBridge: boolean ): RuntimeProtocolConnection['capabilities']['runtimes'][number] { const capability = capabilities.capabilities.find( - (entry) => entry.name === RUNTIME_ACP_CAPABILITY + (entry) => entry.name === RUNTIME_ACP_CAPABILITY_NAME ) const runtimes = capabilities.runtimes.filter( (entry) => entry.runtimeId === runtimeId ) const modelBridgeCapability = capabilities.capabilities.find( - (entry) => entry.name === RUNTIME_MODEL_BRIDGE_CAPABILITY + (entry) => + entry.name === RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME ) if ( !Number.isSafeInteger(capabilities.generation) || diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index 01360d3d..fc34b8a1 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -99,6 +99,108 @@ describe('RuntimeSettingsStore', () => { }) }) + it('migrates version 20 model profiles with empty request customization', async () => { + const { filePath, store } = await createStore() + await store.update(settings()) + const version20 = JSON.parse( + await readFile(filePath, 'utf8') + ) as { + version: number + modelProfiles: Array> + } + version20.version = 20 + for (const profile of version20.modelProfiles) { + delete profile.requestHeaders + delete profile.requestBody + } + await writeFile(filePath, JSON.stringify(version20), 'utf8') + + const migrated = new RuntimeSettingsStore(filePath, cipher, {}) + await expect(migrated.getPublicSettings()).resolves.toMatchObject({ + modelProfiles: [ + expect.objectContaining({ + requestHeaders: {}, + requestBody: {} + }) + ] + }) + await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ + modelProfiles: [ + expect.objectContaining({ + requestHeaders: {}, + requestBody: {} + }) + ] + }) + }) + + it('round-trips model request headers and body without treating them as credentials', async () => { + const { filePath, store } = await createStore() + const profileId = '00000000-0000-4000-8000-000000000010' + const updated = await store.update( + settings({ + modelProfiles: [ + { + id: profileId, + name: 'Customized model', + baseUrl: 'https://provider.example/v1', + modelName: 'model-1', + protocol: 'openai-responses', + authentication: 'none', + supportsImageInput: false, + imageGenerationQuality: 'auto', + requestHeaders: { + 'x-tenant-id': 'tenant-a' + }, + requestBody: { + temperature: 0.2, + metadata: { source: 'goodbuddy' } + }, + apiKey: { action: 'keep' } + } + ], + defaultModelProfileId: profileId, + opencodeModelSource: { kind: 'profile', profileId }, + continueModelSource: { kind: 'profile', profileId } + }) + ) + + expect(updated.modelProfiles[0]).toMatchObject({ + requestHeaders: { + 'x-tenant-id': 'tenant-a' + }, + requestBody: { + temperature: 0.2, + metadata: { source: 'goodbuddy' } + }, + apiKeyConfigured: false + }) + await expect(store.getResolvedSettings()).resolves.toMatchObject({ + modelProfiles: [ + expect.objectContaining({ + requestHeaders: { + 'x-tenant-id': 'tenant-a' + }, + requestBody: { + temperature: 0.2, + metadata: { source: 'goodbuddy' } + }, + apiKey: undefined + }) + ] + }) + const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { + version: number + modelProfiles: Array> + } + expect(persisted.version).toBe(21) + expect(persisted.modelProfiles[0]).toMatchObject({ + requestHeaders: { 'x-tenant-id': 'tenant-a' }, + requestBody: { temperature: 0.2 } + }) + expect(persisted.modelProfiles[0]).not.toHaveProperty('apiKey') + }) + it('replaces only the untouched legacy BigToken seed', async () => { const { filePath, store } = await createStore() await store.update(settings()) @@ -619,7 +721,7 @@ describe('RuntimeSettingsStore', () => { const persisted = JSON.parse( await readFile(filePath, 'utf8') ) as Record - expect(persisted.version).toBe(20) + expect(persisted.version).toBe(21) expect(persisted).not.toHaveProperty( 'deepseekHarnessBinaryPath' ) @@ -898,7 +1000,7 @@ describe('RuntimeSettingsStore', () => { const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { version: number } - expect(persisted.version).toBe(20) + expect(persisted.version).toBe(21) }) it('migrates version 11 and removes the obsolete intranet toggle', async () => { @@ -918,7 +1020,7 @@ describe('RuntimeSettingsStore', () => { version: number intranetCompatibilityEnabled?: boolean } - expect(persisted.version).toBe(20) + expect(persisted.version).toBe(21) expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled') }) @@ -1196,7 +1298,7 @@ describe('RuntimeSettingsStore', () => { (JSON.parse(await readFile(filePath, 'utf8')) as { version: number }).version - ).toBe(20) + ).toBe(21) }) it('migrates version 13 with reranking disabled by default', async () => { @@ -1703,7 +1805,7 @@ describe('RuntimeSettingsStore', () => { version: number modelProfiles: Array> } - expect(persisted.version).toBe(20) + expect(persisted.version).toBe(21) expect(persisted.modelProfiles).toContainEqual( expect.objectContaining({ id: imageId, @@ -2005,7 +2107,7 @@ describe('RuntimeSettingsStore', () => { unknown > expect(saved).toMatchObject({ - version: 20, + version: 21, provider: 'model', continueBinaryPath: '', continueMode: 'chat', @@ -2284,7 +2386,7 @@ describe('RuntimeSettingsStore', () => { version: number modelProfiles: Array> } - expect(persisted.version).toBe(20) + expect(persisted.version).toBe(21) expect(persisted.modelProfiles[0]).not.toHaveProperty('credential') }) diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index 187e525c..92cf68fb 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -20,6 +20,8 @@ import { minimumModelContextWindowTokens, modelAuthenticationSchema, modelProtocolSchema, + modelRequestBodySchema, + modelRequestHeadersSchema, runtimeModelSourceSchema, runtimeCustomizationSettingsSchema, runtimePathSchema, @@ -48,6 +50,7 @@ import { } from './settings-credential-cipher' const credentialSchema = encryptedSettingsCredentialSchema.optional() +const CURRENT_SETTINGS_VERSION = 21 const legacyRuntimeSandboxModeSchema = z.enum([ 'off', 'auto', @@ -276,12 +279,27 @@ const version19StoredSettingsSchema = version18StoredSettingsSchema activeEmbeddingConnectionId: z.string().uuid() }) -const storedSettingsSchema = version19StoredSettingsSchema +const version20StoredSettingsSchema = version19StoredSettingsSchema .omit({ version: true }) .extend({ version: z.literal(20) }) +const storedSettingsSchema = version20StoredSettingsSchema + .omit({ version: true, modelProfiles: true }) + .extend({ + version: z.literal(CURRENT_SETTINGS_VERSION), + modelProfiles: z + .array( + currentStoredModelProfileSchema.extend({ + requestHeaders: modelRequestHeadersSchema, + requestBody: modelRequestBodySchema + }) + ) + .min(1) + .max(20) + }) + type StoredSettings = z.infer export type RuntimeSettingsRollback = { publicSettings: RuntimeSettings @@ -296,6 +314,9 @@ type Version18StoredSettings = z.infer< type Version19StoredSettings = z.infer< typeof version19StoredSettingsSchema > +type Version20StoredSettings = z.infer< + typeof version20StoredSettingsSchema +> type Version16StoredSettings = z.infer< typeof version16StoredSettingsSchema > @@ -442,6 +463,8 @@ export type ResolvedModelProfile = { contextWindowTokens?: number maximumOutputTokens?: number imageGenerationQuality?: RuntimeSettings['imageGenerationQuality'] + requestHeaders?: RuntimeSettings['modelProfiles'][number]['requestHeaders'] + requestBody?: RuntimeSettings['modelProfiles'][number]['requestBody'] apiKey?: string } @@ -468,7 +491,7 @@ type ResolvedModelCredential = { } const defaultSettings: StoredSettings = { - version: 20, + version: CURRENT_SETTINGS_VERSION, provider: defaultRuntimeSettings.provider, modelProfiles: [ { @@ -480,7 +503,9 @@ const defaultSettings: StoredSettings = { authentication: defaultRuntimeSettings.modelAuthentication, supportsImageInput: defaultRuntimeSettings.supportsImageInput, imageGenerationQuality: - defaultRuntimeSettings.imageGenerationQuality + defaultRuntimeSettings.imageGenerationQuality, + requestHeaders: {}, + requestBody: {} } ], defaultModelProfileId, @@ -698,7 +723,7 @@ function migrateVersion18( function migrateVersion19( settings: Version19StoredSettings ): StoredSettings { - return { + return migrateVersion20({ ...settings, version: 20, modelProfiles: settings.modelProfiles.map((profile) => @@ -722,6 +747,20 @@ function migrateVersion19( } : profile ) + }) +} + +function migrateVersion20( + settings: Version20StoredSettings +): StoredSettings { + return { + ...settings, + version: CURRENT_SETTINGS_VERSION, + modelProfiles: settings.modelProfiles.map((profile) => ({ + ...profile, + requestHeaders: {}, + requestBody: {} + })) } } @@ -1014,6 +1053,119 @@ function migrateVersion9( }) } +function migrateVersion3( + settings: z.infer +): StoredSettings { + return migrateVersion4({ + ...settings, + version: 4, + continueMode: 'chat' + }) +} + +function migrateVersion2( + settings: z.infer +): StoredSettings { + return migrateVersion4({ + version: 4, + provider: settings.provider, + modelBaseUrl: settings.modelBaseUrl, + modelName: settings.modelName, + opencodeBaseUrl: settings.opencodeBaseUrl, + opencodeEmbedded: settings.opencodeEmbedded, + opencodeBinaryPath: '', + opencodeConfigPath: '', + continueBinaryPath: migrateContinueCommand( + settings.continueCommand + ), + continueConfigPath: '', + continueMode: 'chat', + workspacePath: settings.workspacePath, + credential: settings.credential, + toolApproval: settings.toolApproval + }) +} + +function migrateLegacySettings( + settings: z.infer +): StoredSettings { + return migrateVersion4({ + version: 4, + provider: + settings.provider === 'bigtoken' ? 'model' : settings.provider, + modelBaseUrl: settings.bigtokenBaseUrl, + modelName: settings.bigtokenModel, + opencodeBaseUrl: settings.opencodeBaseUrl, + opencodeEmbedded: settings.opencodeEmbedded, + opencodeBinaryPath: '', + opencodeConfigPath: '', + continueBinaryPath: migrateContinueCommand( + settings.continueCommand + ), + continueConfigPath: '', + continueMode: 'chat', + workspacePath: settings.workspacePath, + credential: settings.credential, + toolApproval: settings.toolApproval + }) +} + +function parseStoredSettings(value: unknown): StoredSettings { + const version = + value !== null && + typeof value === 'object' && + 'version' in value && + typeof value.version === 'number' + ? value.version + : undefined + switch (version) { + case CURRENT_SETTINGS_VERSION: + return storedSettingsSchema.parse(value) + case 20: + return migrateVersion20(version20StoredSettingsSchema.parse(value)) + case 19: + return migrateVersion19(version19StoredSettingsSchema.parse(value)) + case 18: + return migrateVersion18(version18StoredSettingsSchema.parse(value)) + case 17: + return migrateVersion17(version17StoredSettingsSchema.parse(value)) + case 16: + return migrateVersion16(version16StoredSettingsSchema.parse(value)) + case 15: + return migrateVersion15(version15StoredSettingsSchema.parse(value)) + case 14: + return migrateVersion14(version14StoredSettingsSchema.parse(value)) + case 13: + return migrateVersion13(version13StoredSettingsSchema.parse(value)) + case 12: + return migrateVersion12(version12StoredSettingsSchema.parse(value)) + case 11: + return migrateVersion11(version11StoredSettingsSchema.parse(value)) + case 10: + return migrateVersion10(version10StoredSettingsSchema.parse(value)) + case 9: + return migrateVersion9(version9StoredSettingsSchema.parse(value)) + case 8: + return migrateVersion8(version8StoredSettingsSchema.parse(value)) + case 7: + return migrateVersion7(version7StoredSettingsSchema.parse(value)) + case 6: + return migrateVersion6(version6StoredSettingsSchema.parse(value)) + case 5: + return migrateVersion5(version5StoredSettingsSchema.parse(value)) + case 4: + return migrateVersion4(version4StoredSettingsSchema.parse(value)) + case 3: + return migrateVersion3(version3StoredSettingsSchema.parse(value)) + case 2: + return migrateVersion2(version2StoredSettingsSchema.parse(value)) + default: + return migrateLegacySettings( + legacyStoredSettingsSchema.parse(value) + ) + } +} + export class RuntimeSettingsStore { private settings?: StoredSettings private settingsLoad?: Promise @@ -1044,165 +1196,11 @@ export class RuntimeSettingsStore { const parsed: unknown = JSON.parse(contents) assertSupportedSettingsVersion( parsed, - 20, + CURRENT_SETTINGS_VERSION, (version) => `当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试` ) - const current = storedSettingsSchema.safeParse(parsed) - const version19 = - version19StoredSettingsSchema.safeParse(parsed) - if (current.success) { - this.settings = current.data - } else if (version19.success) { - this.settings = migrateVersion19(version19.data) - } else { - const version18 = - version18StoredSettingsSchema.safeParse(parsed) - if (version18.success) { - this.settings = migrateVersion18(version18.data) - } else { - const version17 = - version17StoredSettingsSchema.safeParse(parsed) - if (version17.success) { - this.settings = migrateVersion17(version17.data) - } else { - const version16 = - version16StoredSettingsSchema.safeParse(parsed) - if (version16.success) { - this.settings = migrateVersion16(version16.data) - } else { - const version15 = - version15StoredSettingsSchema.safeParse(parsed) - if (version15.success) { - this.settings = migrateVersion15(version15.data) - } else { - const version14 = - version14StoredSettingsSchema.safeParse(parsed) - if (version14.success) { - this.settings = migrateVersion14(version14.data) - } else { - const version13 = - version13StoredSettingsSchema.safeParse(parsed) - if (version13.success) { - this.settings = migrateVersion13(version13.data) - } else { - const version12 = - version12StoredSettingsSchema.safeParse(parsed) - if (version12.success) { - this.settings = migrateVersion12(version12.data) - } else { - const version11 = - version11StoredSettingsSchema.safeParse(parsed) - if (version11.success) { - this.settings = migrateVersion11(version11.data) - } else { - const version10 = - version10StoredSettingsSchema.safeParse(parsed) - if (version10.success) { - this.settings = migrateVersion10(version10.data) - } else { - const version9 = - version9StoredSettingsSchema.safeParse(parsed) - if (version9.success) { - this.settings = migrateVersion9(version9.data) - } else { - const version8 = - version8StoredSettingsSchema.safeParse(parsed) - if (version8.success) { - this.settings = migrateVersion8(version8.data) - } else { - const version7 = - version7StoredSettingsSchema.safeParse(parsed) - if (version7.success) { - this.settings = migrateVersion7(version7.data) - } else { - const version6 = - version6StoredSettingsSchema.safeParse(parsed) - if (version6.success) { - this.settings = migrateVersion6(version6.data) - } else { - const version5 = - version5StoredSettingsSchema.safeParse(parsed) - if (version5.success) { - this.settings = migrateVersion5(version5.data) - } else { - const version4 = - version4StoredSettingsSchema.safeParse(parsed) - if (version4.success) { - this.settings = migrateVersion4(version4.data) - } else { - const version3 = - version3StoredSettingsSchema.safeParse(parsed) - if (version3.success) { - this.settings = migrateVersion4({ - ...version3.data, - version: 4, - continueMode: 'chat' - }) - } else { - const version2 = - version2StoredSettingsSchema.safeParse(parsed) - if (version2.success) { - this.settings = migrateVersion4({ - version: 4, - provider: version2.data.provider, - modelBaseUrl: version2.data.modelBaseUrl, - modelName: version2.data.modelName, - opencodeBaseUrl: version2.data.opencodeBaseUrl, - opencodeEmbedded: version2.data.opencodeEmbedded, - opencodeBinaryPath: '', - opencodeConfigPath: '', - continueBinaryPath: migrateContinueCommand( - version2.data.continueCommand - ), - continueConfigPath: '', - continueMode: 'chat', - workspacePath: version2.data.workspacePath, - credential: version2.data.credential, - toolApproval: version2.data.toolApproval - }) - } else { - const legacy = - legacyStoredSettingsSchema.parse(parsed) - this.settings = migrateVersion4({ - version: 4, - provider: - legacy.provider === 'bigtoken' - ? 'model' - : legacy.provider, - modelBaseUrl: legacy.bigtokenBaseUrl, - modelName: legacy.bigtokenModel, - opencodeBaseUrl: legacy.opencodeBaseUrl, - opencodeEmbedded: legacy.opencodeEmbedded, - opencodeBinaryPath: '', - opencodeConfigPath: '', - continueBinaryPath: migrateContinueCommand( - legacy.continueCommand - ), - continueConfigPath: '', - continueMode: 'chat', - workspacePath: legacy.workspacePath, - credential: legacy.credential, - toolApproval: legacy.toolApproval - }) - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } + this.settings = parseStoredSettings(parsed) this.settings = normalizeStoredSettings(this.settings) } catch (error) { if (error instanceof UnsupportedSettingsVersionError) { @@ -1501,6 +1499,8 @@ export class RuntimeSettingsStore { contextWindowTokens?: number maximumOutputTokens?: number imageGenerationQuality: RuntimeSettings['imageGenerationQuality'] + requestHeaders: RuntimeSettings['modelProfiles'][number]['requestHeaders'] + requestBody: RuntimeSettings['modelProfiles'][number]['requestBody'] credentialSource: RuntimeSettings['credentialSource'] } { const profile = @@ -1563,6 +1563,8 @@ export class RuntimeSettingsStore { contextWindowTokens: profile.contextWindowTokens, maximumOutputTokens: profile.maximumOutputTokens, imageGenerationQuality: profile.imageGenerationQuality, + requestHeaders: profile.requestHeaders, + requestBody: profile.requestBody, credentialSource: credential.source } } @@ -1587,6 +1589,8 @@ export class RuntimeSettingsStore { contextWindowTokens: effective.contextWindowTokens, maximumOutputTokens: effective.maximumOutputTokens, imageGenerationQuality: effective.imageGenerationQuality, + requestHeaders: effective.requestHeaders, + requestBody: effective.requestBody, apiKey: effective.apiKey } } @@ -1605,6 +1609,8 @@ export class RuntimeSettingsStore { contextWindowTokens: profile.contextWindowTokens, maximumOutputTokens: profile.maximumOutputTokens, imageGenerationQuality: profile.imageGenerationQuality, + requestHeaders: profile.requestHeaders, + requestBody: profile.requestBody, apiKey: credential.activeApiKey } }) @@ -1690,6 +1696,8 @@ export class RuntimeSettingsStore { imageGenerationQuality: resolved.imageGenerationQuality ?? defaultRuntimeSettings.imageGenerationQuality, + requestHeaders: resolved.requestHeaders ?? {}, + requestBody: resolved.requestBody ?? {}, apiKeyConfigured: credential.configured, credentialSource: credential.source } @@ -1712,6 +1720,8 @@ export class RuntimeSettingsStore { imageGenerationQuality: profile.imageGenerationQuality ?? defaultRuntimeSettings.imageGenerationQuality, + requestHeaders: profile.requestHeaders, + requestBody: profile.requestBody, apiKeyConfigured: credential.configured, credentialSource: credential.source } @@ -2014,6 +2024,8 @@ export class RuntimeSettingsStore { contextWindowTokens: profile.contextWindowTokens, maximumOutputTokens: profile.maximumOutputTokens, imageGenerationQuality: input.imageGenerationQuality, + requestHeaders: profile.requestHeaders, + requestBody: profile.requestBody, apiKey: input.apiKey } : { @@ -2027,6 +2039,8 @@ export class RuntimeSettingsStore { contextWindowTokens: profile.contextWindowTokens, maximumOutputTokens: profile.maximumOutputTokens, imageGenerationQuality: profile.imageGenerationQuality, + requestHeaders: profile.requestHeaders, + requestBody: profile.requestBody, apiKey: { action: 'keep' as const } } ) @@ -2078,7 +2092,9 @@ export class RuntimeSettingsStore { supportsImageInput: profile.supportsImageInput ?? false, contextWindowTokens: profile.contextWindowTokens, maximumOutputTokens: profile.maximumOutputTokens, - imageGenerationQuality: profile.imageGenerationQuality + imageGenerationQuality: profile.imageGenerationQuality, + requestHeaders: profile.requestHeaders ?? {}, + requestBody: profile.requestBody ?? {} } if ( profile.apiKey.action === 'keep' && @@ -2346,7 +2362,7 @@ export class RuntimeSettingsStore { const next: StoredSettings = { ...current, - version: 20, + version: CURRENT_SETTINGS_VERSION, provider: input.provider, modelProfiles, defaultModelProfileId, @@ -2407,7 +2423,7 @@ export class RuntimeSettingsStore { const current = await this.load() const next: StoredSettings = { ...current, - version: 20, + version: CURRENT_SETTINGS_VERSION, runtimeCustomization: parsed } await writeJsonFileAtomically(this.filePath, next) diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 5eb86e65..1209a720 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -4024,6 +4024,94 @@ describe('SettingsPanel runtime files', () => { ) }) + it('edits and saves bounded custom model request headers and body', async () => { + render( + {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + + fireEvent.click(screen.getByRole('tab', { name: '模型连接' })) + await screen.findByDisplayValue('默认模型') + const headers = await screen.findByLabelText('自定义请求头') + const body = screen.getByLabelText('自定义请求体') + expect(headers).toHaveValue('{}') + expect(body).toHaveValue('{}') + expect(headers).toHaveAttribute('maxlength') + expect(body).toHaveAttribute('maxlength') + expect( + screen.getByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/) + ).toBeInTheDocument() + + fireEvent.change(headers, { + target: { + value: JSON.stringify({ 'x-tenant-id': 'tenant-a' }) + } + }) + fireEvent.change(body, { + target: { + value: JSON.stringify({ + temperature: 0.2, + metadata: { source: 'goodbuddy' } + }) + } + }) + fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + + await waitFor(() => + expect(updateRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + modelProfiles: [ + expect.objectContaining({ + id: modelProfileId, + requestHeaders: { + 'x-tenant-id': 'tenant-a' + }, + requestBody: { + temperature: 0.2, + metadata: { source: 'goodbuddy' } + } + }) + ] + }) + ) + ) + }) + + it('keeps invalid custom request JSON in the editor and blocks saving', async () => { + render( + {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + + fireEvent.click(screen.getByRole('tab', { name: '模型连接' })) + await screen.findByDisplayValue('默认模型') + const headers = await screen.findByLabelText('自定义请求头') + fireEvent.change(headers, { + target: { value: '{"Authorization":"replacement"}' } + }) + + expect(headers).toHaveAttribute('aria-invalid', 'true') + expect( + screen.getByText(/不能包含认证或传输保留字段/) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + expect( + await screen.findByText(/自定义请求头无效/) + ).toBeInTheDocument() + expect(updateRuntime).not.toHaveBeenCalled() + expect(headers).toHaveValue('{"Authorization":"replacement"}') + }) + it('keeps saved Runtime sources valid when defaulting a new text profile', async () => { render( & { supportsImageInput: boolean + requestHeadersText: string + requestBodyText: string apiKey: string clearApiKey: boolean } @@ -264,15 +272,55 @@ function settingsErrorMessage(reason: unknown, fallback: string): string { function toModelProfileDrafts( settings: RuntimeSettings ): ModelProfileDraft[] { - return settings.modelProfiles.map((profile) => ({ - ...profile, - supportsImageInput: profile.supportsImageInput ?? false, - maximumOutputTokens: - profile.maximumOutputTokens ?? - defaultAnthropicMaximumOutputTokens, - apiKey: '', - clearApiKey: false - })) + return settings.modelProfiles.map( + ({ requestHeaders, requestBody, ...profile }) => ({ + ...profile, + supportsImageInput: profile.supportsImageInput ?? false, + maximumOutputTokens: + profile.maximumOutputTokens ?? + defaultAnthropicMaximumOutputTokens, + requestHeadersText: JSON.stringify( + requestHeaders ?? {}, + null, + 2 + ), + requestBodyText: JSON.stringify(requestBody ?? {}, null, 2), + apiKey: '', + clearApiKey: false + }) + ) +} + +const maximumRequestHeadersDraftCharacters = + MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaders * + (MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderNameLength + + MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderValueLength * + 2 + + 16) + + 2 +const maximumRequestBodyDraftCharacters = + MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyBytes * 4 + +function parseRequestHeadersDraft(value: string) { + if (value.length > maximumRequestHeadersDraftCharacters) { + return { success: false } as const + } + try { + return modelRequestHeadersSchema.safeParse(JSON.parse(value)) + } catch { + return { success: false } as const + } +} + +function parseRequestBodyDraft(value: string) { + if (value.length > maximumRequestBodyDraftCharacters) { + return { success: false } as const + } + try { + return modelRequestBodySchema.safeParse(JSON.parse(value)) + } catch { + return { success: false } as const + } } function configuredRuntimeSettings( @@ -1336,6 +1384,26 @@ export function SettingsPanel({ const environmentManaged = profile.credentialSource === 'environment' && configured !== undefined + const requestHeaders = parseRequestHeadersDraft( + profile.requestHeadersText + ) + if (!requestHeaders.success) { + throw new Error( + t('errors.invalidModelRequestHeaders', { + name: modelProfileDisplayName(profile) + }) + ) + } + const requestBody = parseRequestBodyDraft( + profile.requestBodyText + ) + if (!requestBody.success) { + throw new Error( + t('errors.invalidModelRequestBody', { + name: modelProfileDisplayName(profile) + }) + ) + } return { id: profile.id, name: profile.name, @@ -1351,6 +1419,8 @@ export function SettingsPanel({ contextWindowTokens: profile.contextWindowTokens, maximumOutputTokens: profile.maximumOutputTokens, imageGenerationQuality: profile.imageGenerationQuality, + requestHeaders: requestHeaders.data, + requestBody: requestBody.data, apiKey: profile.clearApiKey ? ({ action: 'clear' } as const) : profile.authentication === 'api-key' && @@ -1738,6 +1808,8 @@ export function SettingsPanel({ maximumOutputTokens: defaultAnthropicMaximumOutputTokens, imageGenerationQuality: defaultRuntimeSettings.imageGenerationQuality, + requestHeadersText: '{}', + requestBodyText: '{}', apiKeyConfigured: false, credentialSource: 'none', apiKey: '', @@ -2894,11 +2966,20 @@ export function SettingsPanel({ ))} - {selectedModelProfile && (() => { - const profile = selectedModelProfile - const environmentManaged = - profile.credentialSource === 'environment' - return ( + {selectedModelProfile && + (() => { + const profile = selectedModelProfile + const environmentManaged = + profile.credentialSource === 'environment' + const requestHeadersValid = + parseRequestHeadersDraft( + profile.requestHeadersText + ).success + const requestBodyValid = + parseRequestBodyDraft( + profile.requestBodyText + ).success + return (
)} +