diff --git a/CLAUDE.md b/CLAUDE.md index f1a14766a..cbaeeb4f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -441,7 +441,7 @@ revision stale so a later send retries. 失效」的兼容代码。通用路径只留一个兜底调用点,判定逻辑全在这里,便于上游修好 之后整块删掉。**不硬编码服务地址、模型名、业务名词,只按报文特征判定。** -当前三条,前两条围绕同一个症状(内网模型的最大上下文声明值不准,会提前报 400),第三条是它们的兜底: +当前四条,前两条围绕同一个症状(内网模型的最大上下文声明值不准,会提前报 400),第三条是它们的兜底,第四条是「限流绝不能被当成超限」: 1. **上下文超限错误认不出**(`pa_quirks`)。实测报文 `{"object":"error","message":"请求上下文过大","type":"BadRequestError","code":400}` @@ -467,6 +467,17 @@ revision stale so a later send retries. 60s 的退避等待最多 12 次,只有等待耗尽才报错。兜底后的那次重发跳过自动 压缩(`skip_auto_compact_once_`);请求被收下即清零 episode,同回合再被拒 重新开始。学习器只记每个 episode 最初确认拒收的规模,紧急档请求永远不记。 +4. **限流报文不是超限**(`pa_quirks` 的 `FaultKind::RateLimited`,2026-10-08)。 + 内网网关限速「100 次 / 10 分钟」报 `{"message":"您的请求频率已达到限制 (100次/10分钟)…","code":429}`, + 用户反馈它「像被当成超限,会话一直在压缩」且码不可信。按「请求频率 / 过于 + 频繁 / 限流」文案判定,**排在超限与瞬时故障之前**;`is_context_overflow_error` + 对它直接 false,所以学习器与兜底都进不去。`retry_policy` 让文案压过状态码: + 超限文案一律不重试(429 / 5xx 也不,否则同一个超大请求困在无上限退避里进不了 + 压缩链),限流文案一律重试(400 / 451 也重试),硬配额仍终止。限流的本地退避 + 封顶 1 分钟(`kProviderRateLimitRetryMaxDelayMs`,Retry-After 优先),进度文案 + 「服务端限流,等待重试」。配套:`CompactionController::run_auto` 在用户取消时 + 不再走机械修剪 —— 摘要请求被限流、用户按停止,旧实现把「Compaction cancelled.」 + 当摘要失败真丢一段历史,这正是「一直在压缩」的观感来源之一。 **改这块前必读 README 里那三条坑**:观测「要么可信要么整条丢弃」没有中间态 (第一版拿可信下限当收敛下界用,一次 2726 token 的拒绝把 128000 的窗口砍到 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index d3df6db23..1cacfebf2 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -2860,7 +2860,7 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`. 返回所有运行时已启用的模型配置。该路由要求通过 Web 认证,响应包含 `api_key` 原值和 `has_api_key` 布尔值,供编辑表单默认遮罩回填;调用方必须把整个响应视为 敏感数据,日志与错误消息仍不得包含密钥。高级字段有值时会原样返回,包括 -`endpoint_mode`、`max_output_tokens`、`capabilities_source`、`reasoning`、 +`endpoint_mode`、`api_protocol`、`max_output_tokens`、`capabilities_source`、`reasoning`、 `request_headers`、`context_window` 与 `stream_timeout_ms`。 ### `POST /api/models` @@ -2894,6 +2894,14 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`. } ``` +`api_protocol` 仅适用于 `provider:"openai"`,接受 `chat_completions` 或 +`responses`;省略时继续使用 Chat Completions。模型设置的 **API 协议** 下拉框可 +选择 Responses,保存和连接检测使用同一协议。`base_url` 模式下 Responses 会向 +基础地址追加 `/responses`;`full_url` 模式直接使用给定 URL。协议由该字段决定, +不会根据 URL 或模型名称自动推断。更新时省略该字段保留原值,传 `null` 清除并恢复 +默认 Chat Completions;切换到其他 Provider 会清除该字段,其他 Provider 显式传入 +则返回 `UNSUPPORTED_MODEL_OPTION`,未知协议值返回 `INVALID_API_PROTOCOL`。 + 返回包含 `api_key` 原值的模型配置。校验错误使用 `BAD_JSON`、`BAD_REQUEST` 或 `SavedModelEditError` 字符串;持久化失败会回滚内存并返回 `500 PERSIST_FAILED`。 @@ -2908,7 +2916,7 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`. 路径仍区分大小写。 省略 `base_url`、`context_window`、`stream_timeout_ms`、`capabilities`、 -`endpoint_mode`、`max_output_tokens`、`capabilities_source`、`reasoning` 或 +`endpoint_mode`、`api_protocol`、`max_output_tokens`、`capabilities_source`、`reasoning` 或 `request_headers` 会保留原值。显式空 `request_headers` 会清空请求头;对应高级 字段传 `null` 时按各字段合同清除。外部登录器留下的 legacy `readonly:true` 只是 兼容元数据,不阻止编辑。响应包含 `api_key` 原值与 `has_api_key`,并仅允许经 diff --git a/docs/help-source/group3.py b/docs/help-source/group3.py index 8b6b71d85..07ef72f79 100644 --- a/docs/help-source/group3.py +++ b/docs/help-source/group3.py @@ -42,6 +42,7 @@ section("crud", "添加、编辑与删除", '''

模型页的已保存模型列表支持搜索、刷新和新增。每条预设显示名称、Provider、Model ID 与能力标识,可编辑、设为默认或删除。

  1. 添加:点击新增模型,选择 Provider 和 Model ID,填写连接信息,保存模型。
  2. 批量添加:新增时,支持多选的模型目录或探测窗口可选择多个 ID;此时别名字段变为别名前缀(默认为服务商名),每个模型保存为“前缀-Model ID”,重名自动追加 (1)、(2)。
  3. 编辑:打开已有预设,修改需要的字段,再点击保存修改。编辑时保留原有 Provider 身份。
  4. 删除:核对弹窗中的名称后删除预设;会话记录仍保留。
''', '''

已保存密钥不会明文回填。编辑时不填写新密钥通常保留原凭据;需要删除凭据时勾选清除已保存密钥。新增时的别名会随所选模型自动填入,手动改过后不再被覆盖。

名称冲突时,界面提供覆盖已有预设、另存为或取消。不要为绕过冲突随意覆盖还在使用的连接。运行中的会话使用该预设时,删除可能显示会话使用中并被阻止,先结束或切换相关任务。

''', + '

使用 OpenAI 或自定义 OpenAI 兼容服务时,编辑模型连接中的API 协议可选择 Chat Completions 或 Responses。需要 Responses 的模型请选择后者,再点击密钥旁的检测并保存。已有预设默认保持 Chat Completions,不会按模型名自动切换。Base URL 模式填写基础地址(如 https://api.openai.com/v1),客户端追加 /responses;完整端点模式则填写服务要求的完整 URL。协议必须与服务端实际支持的接口一致。

', figure("MD-05", "已保存模型与编辑操作", "展示默认标识、编辑与删除按钮,以及名称冲突弹窗的覆盖、另存为和取消选项。")), section("defaults", "默认模型与会话模型", '''

在已保存模型列表中设为默认,决定新任务使用的默认预设。已经打开的任务有自己的模型选择,可在输入框下方切换。不要把修改默认值理解成自动改写每条历史任务选择的预设名称。

编辑一个预设则是在更新该连接的地址、认证和能力参数。历史任务继续使用这个预设时,应使用更新后的配置;若页面仍显示旧信息,刷新模型列表并检查实际选中的预设。正在执行的请求已发出,不会把新参数倒回已经完成的请求。

''', diff --git a/docs/help/assets/search-index.js b/docs/help/assets/search-index.js index 935994342..05d9f3690 100644 --- a/docs/help/assets/search-index.js +++ b/docs/help/assets/search-index.js @@ -510,7 +510,7 @@ window.ACECODE_HELP_INDEX = [ "title": "添加与管理模型", "url": "manage-models.html", "description": "管理可供任务使用的模型预设,并区分新任务默认值与当前任务的模型选择。", - "text": "添加、编辑与删除 模型页的 已保存模型 列表支持搜索、刷新和新增。每条预设显示名称、Provider、Model ID 与能力标识,可编辑、设为默认或删除。 添加: 点击新增模型,选择 Provider 和 Model ID,填写连接信息,保存模型。 批量添加: 新增时,支持多选的模型目录或探测窗口可选择多个 ID;此时别名字段变为 别名前缀 (默认为服务商名),每个模型保存为“前缀-Model ID”,重名自动追加 (1)、(2)。 编辑: 打开已有预设,修改需要的字段,再点击保存修改。编辑时保留原有 Provider 身份。 删除: 核对弹窗中的名称后删除预设;会话记录仍保留。 已保存密钥不会明文回填。编辑时不填写新密钥通常保留原凭据;需要删除凭据时勾选 清除已保存密钥 。新增时的 别名 会随所选模型自动填入,手动改过后不再被覆盖。 名称冲突时,界面提供覆盖已有预设、另存为或取消。不要为绕过冲突随意覆盖还在使用的连接。运行中的会话使用该预设时,删除可能显示 会话使用中 并被阻止,先结束或切换相关任务。 模型列表显示本地预设名称和默认标识,行内可进入编辑或设置默认模型。操作前先核对要修改的预设名称。 Windows 实际界面 · 点击图片查看原图 默认模型与会话模型 在已保存模型列表中 设为默认 ,决定新任务使用的默认预设。已经打开的任务有自己的模型选择,可在输入框下方切换。不要把修改默认值理解成自动改写每条历史任务选择的预设名称。 编辑一个预设则是在更新该连接的地址、认证和能力参数。历史任务继续使用这个预设时,应使用更新后的配置;若页面仍显示旧信息,刷新模型列表并检查实际选中的预设。正在执行的请求已发出,不会把新参数倒回已经完成的请求。 TUI 命令 生效范围 /model NAME 切换当前会话。 /model --cwd NAME 切换并保存当前工作目录的模型覆盖。 /model --default NAME 切换并更新全局默认模型名称。 这些 NAME 使用 预设名称 。脚本中的 acecode -p --model NAME 同样如此;print 模式恢复会话而没有显式传模型时,会保留会话已保存的模型选择。" + "text": "添加、编辑与删除 模型页的 已保存模型 列表支持搜索、刷新和新增。每条预设显示名称、Provider、Model ID 与能力标识,可编辑、设为默认或删除。 添加: 点击新增模型,选择 Provider 和 Model ID,填写连接信息,保存模型。 批量添加: 新增时,支持多选的模型目录或探测窗口可选择多个 ID;此时别名字段变为 别名前缀 (默认为服务商名),每个模型保存为“前缀-Model ID”,重名自动追加 (1)、(2)。 编辑: 打开已有预设,修改需要的字段,再点击保存修改。编辑时保留原有 Provider 身份。 删除: 核对弹窗中的名称后删除预设;会话记录仍保留。 已保存密钥不会明文回填。编辑时不填写新密钥通常保留原凭据;需要删除凭据时勾选 清除已保存密钥 。新增时的 别名 会随所选模型自动填入,手动改过后不再被覆盖。 名称冲突时,界面提供覆盖已有预设、另存为或取消。不要为绕过冲突随意覆盖还在使用的连接。运行中的会话使用该预设时,删除可能显示 会话使用中 并被阻止,先结束或切换相关任务。 使用 OpenAI 或自定义 OpenAI 兼容服务时,编辑模型连接中的 API 协议 可选择 Chat Completions 或 Responses 。需要 Responses 的模型请选择后者,再点击密钥旁的 检测 并保存。已有预设默认保持 Chat Completions,不会按模型名自动切换。Base URL 模式填写基础地址(如 https://api.openai.com/v1 ),客户端追加 /responses ;完整端点模式则填写服务要求的完整 URL。协议必须与服务端实际支持的接口一致。 模型列表显示本地预设名称和默认标识,行内可进入编辑或设置默认模型。操作前先核对要修改的预设名称。 Windows 实际界面 · 点击图片查看原图 默认模型与会话模型 在已保存模型列表中 设为默认 ,决定新任务使用的默认预设。已经打开的任务有自己的模型选择,可在输入框下方切换。不要把修改默认值理解成自动改写每条历史任务选择的预设名称。 编辑一个预设则是在更新该连接的地址、认证和能力参数。历史任务继续使用这个预设时,应使用更新后的配置;若页面仍显示旧信息,刷新模型列表并检查实际选中的预设。正在执行的请求已发出,不会把新参数倒回已经完成的请求。 TUI 命令 生效范围 /model NAME 切换当前会话。 /model --cwd NAME 切换并保存当前工作目录的模型覆盖。 /model --default NAME 切换并更新全局默认模型名称。 这些 NAME 使用 预设名称 。脚本中的 acecode -p --model NAME 同样如此;print 模式恢复会话而没有显式传模型时,会保留会话已保存的模型选择。" }, { "page": "添加与管理模型", @@ -518,7 +518,7 @@ window.ACECODE_HELP_INDEX = [ "title": "添加、编辑与删除", "url": "manage-models.html#crud", "description": "添加、编辑与删除 模型页的 已保存模型 列表支持搜索、刷新和新增。每条预设显示名称、Provider、Model ID 与能力标识,可编辑、设为默认或删除。 添加: 点击新增模型,选择 Provider 和 Model ID,填写连接信息,保存模型。", - "text": "添加、编辑与删除 模型页的 已保存模型 列表支持搜索、刷新和新增。每条预设显示名称、Provider、Model ID 与能力标识,可编辑、设为默认或删除。 添加: 点击新增模型,选择 Provider 和 Model ID,填写连接信息,保存模型。 批量添加: 新增时,支持多选的模型目录或探测窗口可选择多个 ID;此时别名字段变为 别名前缀 (默认为服务商名),每个模型保存为“前缀-Model ID”,重名自动追加 (1)、(2)。 编辑: 打开已有预设,修改需要的字段,再点击保存修改。编辑时保留原有 Provider 身份。 删除: 核对弹窗中的名称后删除预设;会话记录仍保留。 已保存密钥不会明文回填。编辑时不填写新密钥通常保留原凭据;需要删除凭据时勾选 清除已保存密钥 。新增时的 别名 会随所选模型自动填入,手动改过后不再被覆盖。 名称冲突时,界面提供覆盖已有预设、另存为或取消。不要为绕过冲突随意覆盖还在使用的连接。运行中的会话使用该预设时,删除可能显示 会话使用中 并被阻止,先结束或切换相关任务。 模型列表显示本地预设名称和默认标识,行内可进入编辑或设置默认模型。操作前先核对要修改的预设名称。 Windows 实际界面 · 点击图片查看原图" + "text": "添加、编辑与删除 模型页的 已保存模型 列表支持搜索、刷新和新增。每条预设显示名称、Provider、Model ID 与能力标识,可编辑、设为默认或删除。 添加: 点击新增模型,选择 Provider 和 Model ID,填写连接信息,保存模型。 批量添加: 新增时,支持多选的模型目录或探测窗口可选择多个 ID;此时别名字段变为 别名前缀 (默认为服务商名),每个模型保存为“前缀-Model ID”,重名自动追加 (1)、(2)。 编辑: 打开已有预设,修改需要的字段,再点击保存修改。编辑时保留原有 Provider 身份。 删除: 核对弹窗中的名称后删除预设;会话记录仍保留。 已保存密钥不会明文回填。编辑时不填写新密钥通常保留原凭据;需要删除凭据时勾选 清除已保存密钥 。新增时的 别名 会随所选模型自动填入,手动改过后不再被覆盖。 名称冲突时,界面提供覆盖已有预设、另存为或取消。不要为绕过冲突随意覆盖还在使用的连接。运行中的会话使用该预设时,删除可能显示 会话使用中 并被阻止,先结束或切换相关任务。 使用 OpenAI 或自定义 OpenAI 兼容服务时,编辑模型连接中的 API 协议 可选择 Chat Completions 或 Responses 。需要 Responses 的模型请选择后者,再点击密钥旁的 检测 并保存。已有预设默认保持 Chat Completions,不会按模型名自动切换。Base URL 模式填写基础地址(如 https://api.openai.com/v1 ),客户端追加 /responses ;完整端点模式则填写服务要求的完整 URL。协议必须与服务端实际支持的接口一致。 模型列表显示本地预设名称和默认标识,行内可进入编辑或设置默认模型。操作前先核对要修改的预设名称。 Windows 实际界面 · 点击图片查看原图" }, { "page": "添加与管理模型", diff --git a/docs/help/manage-models.html b/docs/help/manage-models.html index e94dba0bc..23256277c 100644 --- a/docs/help/manage-models.html +++ b/docs/help/manage-models.html @@ -39,6 +39,7 @@

添加、编辑与删除

模型页的已保存模型列表支持搜索、刷新和新增。每条预设显示名称、Provider、Model ID 与能力标识,可编辑、设为默认或删除。

  1. 添加:点击新增模型,选择 Provider 和 Model ID,填写连接信息,保存模型。
  2. 批量添加:新增时,支持多选的模型目录或探测窗口可选择多个 ID;此时别名字段变为别名前缀(默认为服务商名),每个模型保存为“前缀-Model ID”,重名自动追加 (1)、(2)。
  3. 编辑:打开已有预设,修改需要的字段,再点击保存修改。编辑时保留原有 Provider 身份。
  4. 删除:核对弹窗中的名称后删除预设;会话记录仍保留。

已保存密钥不会明文回填。编辑时不填写新密钥通常保留原凭据;需要删除凭据时勾选清除已保存密钥。新增时的别名会随所选模型自动填入,手动改过后不再被覆盖。

名称冲突时,界面提供覆盖已有预设、另存为或取消。不要为绕过冲突随意覆盖还在使用的连接。运行中的会话使用该预设时,删除可能显示会话使用中并被阻止,先结束或切换相关任务。

+

使用 OpenAI 或自定义 OpenAI 兼容服务时,编辑模型连接中的API 协议可选择 Chat Completions 或 Responses。需要 Responses 的模型请选择后者,再点击密钥旁的检测并保存。已有预设默认保持 Chat Completions,不会按模型名自动切换。Base URL 模式填写基础地址(如 https://api.openai.com/v1),客户端追加 /responses;完整端点模式则填写服务要求的完整 URL。协议必须与服务端实际支持的接口一致。

已保存模型列表、默认标识、设为默认与编辑入口
模型列表显示本地预设名称和默认标识,行内可进入编辑或设置默认模型。操作前先核对要修改的预设名称。Windows 实际界面 · 点击图片查看原图

默认模型与会话模型

在已保存模型列表中设为默认,决定新任务使用的默认预设。已经打开的任务有自己的模型选择,可在输入框下方切换。不要把修改默认值理解成自动改写每条历史任务选择的预设名称。

编辑一个预设则是在更新该连接的地址、认证和能力参数。历史任务继续使用这个预设时,应使用更新后的配置;若页面仍显示旧信息,刷新模型列表并检查实际选中的预设。正在执行的请求已发出,不会把新参数倒回已经完成的请求。

TUI 命令生效范围
/model NAME切换当前会话。
/model --cwd NAME切换并保存当前工作目录的模型覆盖。
/model --default NAME切换并更新全局默认模型名称。
diff --git a/docs/releases/v1.0.1.md b/docs/releases/v1.0.1.md new file mode 100644 index 000000000..d59be350c --- /dev/null +++ b/docs/releases/v1.0.1.md @@ -0,0 +1,8 @@ +# ACECode v1.0.1 + +- OpenAI 模型设置新增 Responses 协议选项,支持流式输出、工具调用续轮、推理状态回放和会话恢复;已有配置继续使用 Chat Completions。 +- 修复模型网关限流被误判为上下文超限的问题,区分限流等待与上下文压缩,并改进重试等待提示和退避策略。 +- 在自动压缩等待期间点击停止时,保留原有会话历史,避免把用户取消当作摘要失败后继续裁剪。 +- 修复内置浏览器尚未就绪时打开本地 HTML 页面可能失败的问题。 + +升级安装完成后请重启 ACECode。Agent Browser 集成在 Desktop 中,无需独立浏览器宿主或扩展。 diff --git a/openspec/changes/add-openai-responses-protocol/.openspec.yaml b/openspec/changes/add-openai-responses-protocol/.openspec.yaml new file mode 100644 index 000000000..01660bab0 --- /dev/null +++ b/openspec/changes/add-openai-responses-protocol/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-08 diff --git a/openspec/changes/add-openai-responses-protocol/design.md b/openspec/changes/add-openai-responses-protocol/design.md new file mode 100644 index 000000000..705bd14e0 --- /dev/null +++ b/openspec/changes/add-openai-responses-protocol/design.md @@ -0,0 +1,33 @@ +## Context + +See proposal.md. OpenAiCompatProvider owns Chat Completions normalization, attachment routing, request headers and cache hints. Grok already has a dedicated Responses codec and transport. Session content_parts and agent Done events can preserve opaque provider blocks. Factory fingerprints determine whether a provider snapshot may be reused. + +## Goals / Non-Goals + +Goals: a selectable API-key Responses provider, complete local tool loop, deterministic fixture coverage, unchanged legacy defaults. + +Non-goals: automatic model-name-based protocol migration, hosted OpenAI tools, asynchronous tool execution, native compaction and configuration_update. Existing local compaction continues through the selected provider's normal inference API. + +## Decisions + +1. Add optional ModelProfile.api_protocol with a missing-value legacy default. Carry it through draft/API/UI/equality and provider fingerprinting. Use a distinct OpenAiResponsesProvider implementation while retaining provider kind openai. This avoids guessing from URLs or model strings and permits compatible gateways. +2. Split a pure openai_responses codec from transport. Reuse established Chat request normalization for role/tool repair and attachment policy, then convert to Responses input/tools with max_output_tokens and nested reasoning. Validate canonical histories when projecting provider-native items. Explicit strict=false preserves existing tool schema semantics. +3. Use store=false and reasoning.encrypted_content, without previous_response_id. Persist ordered output under content_parts entries with type openai_responses_item and item containing the raw output object. Canonical content and tool calls remain the application-owned truth; replay whole native groups only if they still match. Other providers ignore these opaque parts. +4. SSE parser accepts normal text/reasoning/refusal/function/usage events. Function execution is enabled only after a successful terminal envelope. Validate known events; ignore unknown events. Final output items replace preliminary items so encrypted state is complete. Preserve message phase and avoid double emission from output_item.done and response.completed. +5. Transport uses existing retry policy and idle-progress cancellation boundaries, fresh parser state per retry, standard retry/reset events, and non-retryable quota classification. The selected protocol applies to streaming, non-streaming, model probes and local compaction. +6. Root integrates factory, base capability seams and native history boundaries; separate workers own codec, transport and configuration/UI. No simultaneous edits to shared files. Sources are discovered by CMake's existing group ownership; validate regenerated source ownership. + +## Risks / Trade-offs + +Release integration: Responses uses the same body-aware retry delay cap as Chat Completions and Anthropic. Rate-limit text takes precedence over unreliable gateway status codes; context overflow must reach recovery without retrying the same oversized request, while hard quota stays terminal. Explicit Retry-After retains the shared server-delay policy. + +- Opaque history can become stale after repair -> validate against canonical content and calls, and test resume/repair paths. +- Stream completion can duplicate or prematurely execute tools -> reconcile final envelope, emit only successful complete calls, and test failure after partial calls. +- Existing Chat image adaptation moves tool images into user messages -> preserve current routing and test image payloads through Responses conversion. +- Gateways differ in optional fields -> use standard Responses fields and make selection explicit; preserve old Chat defaults. +- Pre-request token estimates remain approximate and include opaque native state -> preserve the existing conservative estimator; actual Responses input/output/cache/reasoning usage remains authoritative when reported. Provider-specific tokenization is outside this protocol adapter. +- Shared build output may be occupied -> reuse the current Windows build, load MSVC environment and build with --parallel 1. Inspection found the current build/ Visual Studio Release output was updated on October 8; available Ninja directories were stale September snapshots, so validation uses build/. + +## Migration Plan + +No automatic configuration migration. Users select Responses in existing OpenAI model settings. Rollback selects Chat Completions on endpoints/models supporting it. Validate parser and local HTTP fixtures, session history round-trip, provider factory and profile tests, full Web tests/build, layer checks, strict OpenSpec validation and diff checks. Live paid-provider verification requires available credentials and is reported separately. diff --git a/openspec/changes/add-openai-responses-protocol/proposal.md b/openspec/changes/add-openai-responses-protocol/proposal.md new file mode 100644 index 000000000..16d79590c --- /dev/null +++ b/openspec/changes/add-openai-responses-protocol/proposal.md @@ -0,0 +1,23 @@ +## Why + +ACECode's API-key OpenAI provider only encodes Chat Completions, while current OpenAI models require Responses for reasoning with agent tools. Changing the endpoint URL alone cannot translate requests, stream events, or replay opaque reasoning state. + +## What Changes + +- Add explicit OpenAI model profile protocol selection, preserving Chat Completions for existing profiles. +- Implement Responses request conversion, cancellable HTTP and SSE transport, typed failures and retry isolation. +- Support text, image input, reasoning summaries, function tools, tool results, usage, refusals and incomplete responses. +- Persist and replay ordered provider-owned output items, including encrypted reasoning and message phase, across tool turns and resumed sessions. +- Expose protocol selection in the existing model editor and document the setting. + +## Capabilities + +### New Capabilities +- `openai-responses`: Stateless Responses inference and agent tool continuation with explicit protocol selection. + +### Modified Capabilities +- `web-model-management`: OpenAI profiles expose and persist protocol selection. + +## Impact + +Model profile configuration, saved model editor/API, provider factory, new Responses adapter and parser, model history preservation, Web model settings and focused tests. No new external dependency. Existing Chat Completions, Anthropic, Copilot and Grok configurations retain their current transport. Native compaction, hosted tools, async tool execution and mid-turn steering are separate capabilities. diff --git a/openspec/changes/add-openai-responses-protocol/specs/openai-responses/spec.md b/openspec/changes/add-openai-responses-protocol/specs/openai-responses/spec.md new file mode 100644 index 000000000..a77956ab7 --- /dev/null +++ b/openspec/changes/add-openai-responses-protocol/specs/openai-responses/spec.md @@ -0,0 +1,53 @@ +## Purpose + +Allow ACECode to run its agent tools against OpenAI Responses endpoints while preserving stateless conversation history and existing provider compatibility. + +## ADDED Requirements + +### Requirement: Explicit protocol selection +OpenAI model profiles SHALL accept `api_protocol` values `chat_completions` and `responses`. Omission SHALL preserve Chat Completions. Protocol selection SHALL participate in runtime provider identity and persist across configuration and API round trips. Other provider kinds SHALL reject this option. + +#### Scenario: Existing profile is loaded +- **WHEN** an OpenAI profile without api_protocol is loaded +- **THEN** requests retain the existing Chat Completions behavior + +#### Scenario: Responses profile is selected +- **WHEN** an OpenAI profile selects responses +- **THEN** base URLs target /responses and full URLs are used exactly, with Responses request and response shapes + +### Requirement: Responses agent inference +Responses inference SHALL support system and user messages, image inputs through existing vision routing, assistant text, reasoning summaries, function definitions and function results. Requests SHALL use stateless history with storage disabled and request encrypted reasoning. Output limits and reasoning effort SHALL use Responses fields. Function schemas SHALL not silently acquire strict requirements. + +#### Scenario: Model calls local tools +- **WHEN** a successful response contains parallel function calls +- **THEN** ACECode receives each call exactly once and returns each result under the original call_id in the next request + +#### Scenario: Streaming response arrives +- **WHEN** text and reasoning SSE events precede a successful terminal event +- **THEN** output is displayed incrementally and usage and final output metadata are retained + +### Requirement: Native output replay +Responses assistant output items SHALL retain their order and opaque provider fields, including encrypted reasoning and message phase, through persistence and subsequent requests. Replay SHALL avoid duplicate text and tool calls. Native items inconsistent with repaired or edited canonical history SHALL not resurrect removed content or calls. + +#### Scenario: Resume a session after a tool turn +- **WHEN** a saved session containing native Responses output is reopened +- **THEN** its next request preserves the original reasoning items and call/result linkage + +#### Scenario: History no longer matches native output +- **WHEN** canonical assistant content or tool calls have been repaired or edited +- **THEN** requests use the current canonical history without replaying stale native items + +### Requirement: Reliable stream termination +Responses inference SHALL treat transport truncation, malformed known events and explicit provider failures as failures, and SHALL ignore unknown future events safely. Retry attempts SHALL isolate provisional output. Cancellation SHALL stop transport promptly. Incomplete or failed responses SHALL not execute partial tools. + +#### Scenario: Stream is cut before completion +- **WHEN** EOF or DONE arrives without a terminal Responses event +- **THEN** the request reports a structured failure and no incomplete tool call is executed + +#### Scenario: Transient failure is retried +- **WHEN** a retryable provider failure occurs after partial output +- **THEN** the next attempt replaces provisional text, reasoning, calls and native history + +#### Scenario: Permanent failure or cancellation +- **WHEN** the provider reports exhausted quota or the user cancels +- **THEN** inference terminates without an automatic retry loop diff --git a/openspec/changes/add-openai-responses-protocol/specs/web-model-management/spec.md b/openspec/changes/add-openai-responses-protocol/specs/web-model-management/spec.md new file mode 100644 index 000000000..bfad65b65 --- /dev/null +++ b/openspec/changes/add-openai-responses-protocol/specs/web-model-management/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: OpenAI protocol editor +The existing model editor SHALL expose Chat Completions and Responses protocol choices for OpenAI profiles. Selection SHALL persist through create, edit and test actions. Existing profiles SHALL display Chat Completions by default, and non-OpenAI profiles SHALL not send this field. + +#### Scenario: Create and test a Responses model +- **WHEN** a user selects Responses for an OpenAI profile and tests or saves it +- **THEN** the daemon receives api_protocol responses and uses that protocol + +#### Scenario: Reopen or change provider +- **WHEN** a saved Responses profile is edited +- **THEN** the editor restores Responses, and switching to another provider removes the OpenAI-specific option diff --git a/openspec/changes/add-openai-responses-protocol/tasks.md b/openspec/changes/add-openai-responses-protocol/tasks.md new file mode 100644 index 000000000..3703cc284 --- /dev/null +++ b/openspec/changes/add-openai-responses-protocol/tasks.md @@ -0,0 +1,34 @@ +## 1. Configuration and selection + +- [x] 1.1 Persist and validate api_protocol through profile/draft/API equality and Web editor; verify profile/draft/handler/Web tests. +- [x] 1.2 Route Responses profiles through factory and include protocol in provider fingerprints; verify provider factory regression tests and legacy defaults. + +## 2. Protocol and transport + +- [x] 2.1 Implement Responses request/response conversion and strict SSE parsing; verify codec tests for text, images, reasoning, refusal, tools, usage and malformed/incomplete streams. +- [x] 2.2 Implement cancellable Responses HTTP/SSE transport with retry isolation and correct endpoint/header handling; verify local server tests including continuation, truncation and cancellation. +- [x] 2.3 Verify ordered native output survives session persistence and agent tool continuation, and stale native items cannot override canonical history; add focused integration regressions. + +## 3. Delivery validation + +- [x] 3.1 Document model protocol selection and API compatibility; verify documented fields against implemented profile/API behavior. +- [x] 3.2 Run Windows provider/config/session/agent regression tests, Web pnpm test/build, source-layer checks, strict OpenSpec validation and git diff --check; record results and runtime limitations. +- [x] 3.3 Release integration: apply the shared rate-limit retry delay policy to Responses and verify gateway error classification and cancellation alongside the integrated changes. + +## Validation record (2026-10-08) + +- Windows MSVC Release: `cmake --build build --config Release --target acecode_unit_tests --parallel 1` passed after loading `scripts/dev_windows_env.bat`. +- `python scripts/refactor/run_fast_tests.py --binary build/tests/Release/acecode_unit_tests.exe --profile full --shards 1 --filter "*Provider*.*:*Responses*.*:*SavedModel*.*:ModelsHandler.*:ModelConnectionTest*.*:SessionSerializer.*:SessionModelBinding.*:ModelProfileRuntimeOptions.*:AgentLoopToolProtocolNames.*:AgentLoopEmptyResponse*.*:TurnRecoveryTest.*:ToolExecutor*.*:OpenAiContentParts*.*:ToolImageFeedback*.*" --output build/responses-regression-tests.json`: 404 executed, zero failures/skips. Inventory 6213; the rest of the complete unit suite was not run. The executed set includes all 17 OpenAiResponsesTest and 15 OpenAiResponsesProviderTest cases. +- Explicitly checked the linked binary's test inventory after MSBuild regeneration to ensure the new codec suite was present. +- `pnpm test` and `pnpm build` in web/ passed. The initial Web build encountered a temporary output lock during concurrent CMake asset reading; the final build succeeded and the final CMake generation embedded its 315 assets. +- Edge browser: 8 checks passed for default protocol, keyboard selection, connection-test/save payloads, reopening a Responses profile and 420px dark layout. Screenshots inspected in desktop light and mobile dark modes. +- `scripts/layers/check_layers.py --repo . --layout final --strict`, `check_ownership.py --strict`, and `check_file_size.py --strict` passed including all new files; no ownership baseline exemptions added. +- `openspec validate add-openai-responses-protocol --strict` and `git diff --check` passed. +- Live paid OpenAI inference, installed Desktop runtime, non-Windows native builds and release packaging were not exercised. Existing running Desktop processes remain unchanged. No commit, push or release was performed. + +## Release integration validation (2026-10-08) + +- Responses now uses the shared body-aware rate-limit retry cap. Added local HTTP regression coverage for rate-limit text with HTTP 400, context-overflow text with HTTP 429, hard quota, and cancellation during the retry callback. +- Windows Release build and full suite passed: inventory 6224, executed 6223, skipped 9, failed 0, not_run 0; one test remains disabled. Evidence: `build/release-1.0.1-tests.json`, isolated logs `N:/acecode-gtest-iso/194305`. +- Full `pnpm test` and `pnpm build`, strict layer/ownership/file-size/doc-path/include/map checks, strict OpenSpec validation and `git diff --check` passed. +- Live paid-provider inference remains unverified. Cross-platform packaging and exact released-package verification belong to the v1.0.1 release gate. diff --git a/src/adapters/pa/README.md b/src/adapters/pa/README.md index c5db08cdd..0e9433320 100644 --- a/src/adapters/pa/README.md +++ b/src/adapters/pa/README.md @@ -66,8 +66,54 @@ - 单独的「重试」二字 —— 额度用完的报文写的是「请切换模型后重试」,那是要人换 模型,不是让程序再发一次。 -分类顺序上**上下文超限优先**:超限报文也可能带「请稍候重试」的客套话,但盲目 -重试同一个超大请求只会再撞一次墙,必须先走压缩。 +分类顺序上**上下文超限优先于瞬时故障**:超限报文也可能带「请稍候重试」的客套话, +但盲目重试同一个超大请求只会再撞一次墙,必须先走压缩。(限流又优先于这两者, +见第 5 条。) + +### 5. 限流报文(`pa_quirks.{hpp,cpp}`,2026-10-08) + +**观测报文**(HTTP 429,SSE 形式返回,body 带 `data:` 前缀): + +```json +{"object":"error","message":"您的请求频率已达到限制 (100次/10分钟)。当前已使用: 100次。","type":"BadRequestError","code":429} +``` + +**背景**:内网网关加了限速(100 次 / 10 分钟)之后,用户反馈这条报文「似乎被当成 +上下文超限的中断,导致上下文一直在压缩」,并强调网关的错误码不可信。排查结论 +分两半: + +- 判定层**没有**把它当超限 —— 它不含任何超限 needle,码也过不了通用路径的 + 400/413/422 门;provider 层对 429 是无上限重试。但「限流」在这里没有被建模, + 是否误判完全押在「needle 恰好不命中」上,而且**码一旦不是 429**(这套网关的 + 惯例)它就落到 `retry_policy` 的「4xx 一律终止」上,一次限流断一个回合。 +- 用户看到的「一直在压缩」另有来源:摘要压缩请求自己遇到 429 时按 1s、2s、4s… + 封顶 20 分钟退避,界面长时间停在「压缩请求暂时不可用,等待重试」;此时按停止, + 旧实现把「Compaction cancelled.」当作摘要失败进了机械修剪,每按一次停止真丢 + 一段历史。主请求的 429 退避文案「网络暂时不可用」也把人引向错误方向。 + +**适配**(四处,只有前两处在本目录): + +1. `FaultKind::RateLimited` + `is_rate_limited()`:按「请求频率 / 过于频繁 / 限流」 + 等中文特征串判定,**排在超限与瞬时故障之前**——「频率」说的是时间窗口,比 + 「过大」更具体;判成超限的代价是压缩丢历史,判成限流的代价只是多等一会儿。 + `is_context_overflow_error` 对 RateLimited 直接返回 false,不再给状态码 + + 英文 needle 路径机会。 +2. `retry_policy::provider_http_error_is_retryable` 让文案优先于状态码:写明 + 超限的一律不重试(哪怕是 429 / 5xx —— 否则同一个超大请求被困在无上限退避里, + 永远进不了压缩 / 兜底链);写明限流的一律重试(哪怕是 400 / 451);硬配额仍终止。 +3. 限流(429 或文案判定)的本地退避封顶 1 分钟(`kProviderRateLimitRetryMaxDelayMs`), + 服务端 `Retry-After` 仍优先、20 分钟封顶;进度文案改为「服务端限流,等待重试」。 +4. `CompactionController::run_auto` 在用户取消时不再进机械修剪。 + +学习器(第 2 条)与兜底(第 4 条)都只由 `is_context_overflow` 触发,限流报文 +因此天然进不去;`tests/agent/agent_loop_pa_rescue_test.cpp::RateLimitErrorNeverEntersRescueOrBudgetLearner` +把这条钉死。其余回归:`tests/pa/pa_quirks_test.cpp` 的 RateLimit* 五条、 +`tests/provider/retry_policy_test.cpp` 的 RateLimitBackoffCapsAtOneMinute / +ServerRetryAfterIsHonoredBeyondRateLimitCap、`tests/agent/progress/agent_progress_test.cpp::RateLimitedRetryUsesDedicatedLabel`、 +`tests/agent/agent_loop_compact_events_test.cpp::CancelledAutoCompactDoesNotPruneHistory`。 + +**没做**:按报文里的「N 次 / M 分钟」反推窗口长度来定等待时长 —— 那是解析业务 +文案,超出「按特征判类别」的边界;服务端给 `Retry-After` 才是正道。 ### 2. 声明的上下文窗口不准(`pa_context_budget.{hpp,cpp}`) diff --git a/src/adapters/pa/pa_quirks.cpp b/src/adapters/pa/pa_quirks.cpp index 10b4c84d5..87487a733 100644 --- a/src/adapters/pa/pa_quirks.cpp +++ b/src/adapters/pa/pa_quirks.cpp @@ -57,6 +57,29 @@ const std::vector& transient_upstream_needles() { return needles; } +// 服务端限流的中文特征串。收录标准:必须专指「单位时间内请求太多」—— 它描述 +// 的是时间窗口,不是这次请求的大小。 +// +// 起因:网关把限流报成 {"message":"您的请求频率已达到限制 (100次/10分钟)。 +// 当前已使用: 100次。","type":"BadRequestError","code":429}。这类网关的状态码 +// 不可信(同一套网关把瞬时故障编成 400,见 transient_upstream_needles),所以 +// 限流也必须能按文案认出来:code 不是 429 时通用重试策略会把它当成终止性的 +// 客户端错误,一次限流就断一个回合。 +const std::vector& rate_limit_needles() { + static const std::vector needles = { + // 实测报文:「您的请求频率已达到限制 (100次/10分钟)」 + "请求频率", + "访问频率", + "调用频率", + "频率已达到限制", + "频率超限", + "频率过高", + "过于频繁", + "限流", + }; + return needles; +} + bool contains_any(const std::string& text, const std::vector& needles) { for (const auto& needle : needles) { @@ -70,6 +93,12 @@ bool contains_any(const std::string& text, FaultKind classify_error_text(const std::string& text) { if (!enabled()) return FaultKind::None; if (text.empty()) return FaultKind::None; + // 顺序有意:限流最优先。一条写明「频率 / 限流」的报文说的是时间窗口, + // 与请求大小无关;把它判成上下文超限会触发压缩与兜底收缩,白丢历史 + // (反馈:内网限速报文被当成超限,会话一直在压缩)。 + if (contains_any(text, rate_limit_needles())) { + return FaultKind::RateLimited; + } if (contains_any(text, context_overflow_needles())) { return FaultKind::ContextOverflow; } @@ -85,6 +114,10 @@ bool is_transient_upstream(const std::string& error_text) { return classify_error_text(error_text) == FaultKind::TransientUpstream; } +bool is_rate_limited(const std::string& error_text) { + return classify_error_text(error_text) == FaultKind::RateLimited; +} + FaultKind classify(const ProviderErrorInfo& info) { if (!enabled()) return FaultKind::None; if (!info.has_error()) return FaultKind::None; diff --git a/src/adapters/pa/pa_quirks.hpp b/src/adapters/pa/pa_quirks.hpp index 396f2d76e..b0f7aee2b 100644 --- a/src/adapters/pa/pa_quirks.hpp +++ b/src/adapters/pa/pa_quirks.hpp @@ -22,6 +22,10 @@ enum class FaultKind { // 上游的瞬时故障(服务抽风、网络波动、处理超时)。报文自己就写着「请稍候 // 重试」,但被塞在 4xx 里返回,通用重试策略会当成客户端错误直接放弃。 TransientUpstream, + // 服务端限流:报文写明「请求频率达到限制 / 过于频繁 / 限流」。它只与时间 + // 窗口有关、与请求大小无关 —— 等一会儿原样重发就能过。绝不能按上下文超限 + // 处理(压缩会白丢历史),也不参与上下文预算学习。 + RateLimited, }; // 纯文本判定。text 可以是 display_message、raw_body、pretty_json 的任意 @@ -38,4 +42,8 @@ bool is_transient_upstream(const std::string& error_text); // classify() == ContextOverflow 的便捷形式,供 compact.cpp 的通用判定兜底。 bool is_context_overflow(const ProviderErrorInfo& info); +// classify_error_text() == RateLimited 的便捷形式,供 retry_policy 在状态码 +// 不可信时按文案放行重试、并给限流单独的退避节奏。 +bool is_rate_limited(const std::string& error_text); + } // namespace acecode::pa diff --git a/src/adapters/provider/anthropic_provider.cpp b/src/adapters/provider/anthropic_provider.cpp index 2f968251d..554c9ab04 100644 --- a/src/adapters/provider/anthropic_provider.cpp +++ b/src/adapters/provider/anthropic_provider.cpp @@ -430,15 +430,16 @@ void emit_retry_resume_event(const StreamCallback& callback, callback(evt); } -int retry_after_delay_ms(const cpr::Header& headers, - std::uint64_t retry_number) { +int retry_after_delay_ms(const cpr::Header& headers, std::uint64_t retry_number, + int status_code, const std::string& body) { std::optional server_delay; const std::string retry_after = header_value_ci(headers, "retry-after"); if (!retry_after.empty()) { server_delay = parse_retry_after_ms(retry_after); } return static_cast( - provider_retry_delay_ms(retry_number, server_delay)); + provider_retry_delay_ms(retry_number, server_delay, + provider_retry_max_delay_ms(status_code, body))); } bool find_event_delimiter(const std::string& buffer, @@ -1244,7 +1245,8 @@ ChatResponse AnthropicProvider::parse_sse_stream( } if (error_info.retryable) { - const int delay_ms = retry_after_delay_ms(r.header, attempt); + const int delay_ms = retry_after_delay_ms( + r.header, attempt, error_info.status_code, error_info.raw_body); error_info.retry_attempt = saturating_retry_attempt(attempt); error_info.retry_max_attempts = -1; error_info.retry_delay_ms = delay_ms; diff --git a/src/adapters/provider/openai_provider.cpp b/src/adapters/provider/openai_provider.cpp index 772823606..d3f1596e9 100644 --- a/src/adapters/provider/openai_provider.cpp +++ b/src/adapters/provider/openai_provider.cpp @@ -676,15 +676,20 @@ void emit_retry_resume_event(const StreamCallback& callback, callback(evt); } +// 限流(429 / 按文案判定)的本地退避封顶 1 分钟,其它故障仍封顶 20 分钟; +// 服务端 Retry-After 优先,见 retry_policy。 int retry_after_delay_ms(const cpr::Header& headers, - std::uint64_t retry_number) { + std::uint64_t retry_number, + int status_code, + const std::string& body) { std::optional server_delay; const std::string retry_after = header_value_ci(headers, "retry-after"); if (!retry_after.empty()) { server_delay = parse_retry_after_ms(retry_after); } return static_cast( - provider_retry_delay_ms(retry_number, server_delay)); + provider_retry_delay_ms(retry_number, server_delay, + provider_retry_max_delay_ms(status_code, body))); } void push_openai_text_part(nlohmann::json& parts, const std::string& text) { @@ -1970,7 +1975,8 @@ ChatResponse OpenAiCompatProvider::parse_sse_stream( } if (error_info.retryable) { - const int delay_ms = retry_after_delay_ms(r.header, attempt); + const int delay_ms = retry_after_delay_ms( + r.header, attempt, error_info.status_code, error_info.raw_body); error_info.retry_attempt = saturating_retry_attempt(attempt); error_info.retry_max_attempts = -1; error_info.retry_delay_ms = delay_ms; diff --git a/src/adapters/provider/openai_provider.hpp b/src/adapters/provider/openai_provider.hpp index acd34d55d..7cae8cbaf 100644 --- a/src/adapters/provider/openai_provider.hpp +++ b/src/adapters/provider/openai_provider.hpp @@ -100,7 +100,7 @@ class OpenAiCompatProvider : public LlmProvider { return request_options_; } - std::string request_url() const { + virtual std::string request_url() const { return request_options_.endpoint_mode == "full_url" ? base_url_ : base_url_ + "/chat/completions"; @@ -146,6 +146,9 @@ class OpenAiCompatProvider : public LlmProvider { // the default implementation recognizes verified official endpoints only. virtual bool supports_prompt_cache_key() const; virtual bool supports_compaction_tool_choice_none() const; + bool remove_rejected_prompt_cache_key( + nlohmann::json& body, const std::string& url, + int status_code, const std::string& error_body); // Parse a chat completions response JSON (reusable by CopilotProvider) static ChatResponse parse_response(const nlohmann::json& j); @@ -184,10 +187,6 @@ class OpenAiCompatProvider : public LlmProvider { const StreamCallback& callback, std::atomic* abort_flag, const ChatRequestOptions* call_options); - bool remove_rejected_prompt_cache_key( - nlohmann::json& body, const std::string& url, - int status_code, const std::string& error_body); - // Leaf lock: copies request state only; never held during I/O/callbacks. mutable std::mutex prompt_cache_mu_; std::string prompt_cache_key_; diff --git a/src/adapters/provider/openai_responses.cpp b/src/adapters/provider/openai_responses.cpp new file mode 100644 index 000000000..24f7c71af --- /dev/null +++ b/src/adapters/provider/openai_responses.cpp @@ -0,0 +1,734 @@ +#include "openai_responses.hpp" + +#include "retry_policy.hpp" + +#include +#include +#include + +namespace acecode { +namespace { + +using Json = nlohmann::json; + +bool has_string(const Json& value, const char* key) { + return value.is_object() && value.contains(key) && value[key].is_string(); +} + +std::string text_field(const Json& value, const char* key) { + return has_string(value, key) ? value[key].get() : std::string{}; +} + +bool has_array(const Json& value, const char* key) { + return value.is_object() && value.contains(key) && value[key].is_array(); +} + +int nonnegative_int(const Json& value, const char* key, int fallback = 0) { + if (!value.is_object() || !value.contains(key)) return fallback; + const auto& number = value[key]; + if (number.is_number_unsigned()) { + return static_cast((std::min)(number.get(), + static_cast((std::numeric_limits::max)()))); + } + if (!number.is_number_integer()) return fallback; + const auto raw = number.get(); + if (raw < 0) return fallback; + return static_cast((std::min)(raw, + static_cast((std::numeric_limits::max)()))); +} + +bool valid_index(const Json& value, const char* key) { + if (!value.is_object() || !value.contains(key)) return false; + const auto& number = value[key]; + return number.is_number_integer() && number >= 0 && + number <= (std::numeric_limits::max)(); +} + +bool prefix_of(const std::string& prefix, const std::string& text) { + return text.size() >= prefix.size() && + text.compare(0, prefix.size(), prefix) == 0; +} + +const Json& error_payload(const Json& event) { + if (event.is_object()) { + if (event.contains("error") && !event["error"].is_null()) { + return event["error"]; + } + if (event.contains("response") && event["response"].is_object()) { + return error_payload(event["response"]); + } + } + return event; +} + +ProviderErrorInfo response_error(const Json& event, ProviderErrorKind kind, + const std::string& fallback) { + ProviderErrorInfo result; + result.kind = kind; + result.provider = "openai"; + const auto& payload = error_payload(event); + result.display_message = payload.is_string() + ? payload.get() : text_field(payload, "message"); + if (result.display_message.empty()) result.display_message = fallback; + result.body_is_json = event.is_object(); + result.raw_body = event.dump(); + if (result.body_is_json) result.pretty_json = event.dump(2); + result.request_id = text_field(event, "request_id"); + if (result.request_id.empty()) result.request_id = text_field(payload, "request_id"); + if (kind == ProviderErrorKind::Unknown) { + result.status_code = nonnegative_int(payload, "status_code"); + const auto code = text_field(payload, "code"); + if (result.status_code == 0) { + if (code == "rate_limit_exceeded" || code == "rate_limit_reached") { + result.status_code = 429; + } else if (code == "server_error" || code == "internal_server_error") { + result.status_code = 500; + } else if (code == "overloaded_error") { + result.status_code = 503; + } + } + result.retryable = !provider_error_body_has_hard_quota(result.raw_body) && + provider_http_error_is_retryable(result.status_code, result.raw_body); + } + return result; +} + +ChatResponse invalid_response(const Json& envelope, const std::string& message, + ProviderErrorKind kind = ProviderErrorKind::MalformedJson) { + ChatResponse result; + result.finish_reason = "error"; + result.provider_error = response_error(envelope, kind, message); + result.content = result.provider_error.display_message; + return result; +} + +void merge_usage(TokenUsage& target, const Json& usage) { + if (!usage.is_object()) return; + target.prompt_tokens = nonnegative_int(usage, "input_tokens"); + target.completion_tokens = nonnegative_int(usage, "output_tokens"); + const auto total = static_cast(target.prompt_tokens) + + target.completion_tokens; + target.total_tokens = nonnegative_int(usage, "total_tokens", + static_cast((std::min)(total, + static_cast((std::numeric_limits::max)())))); + if (usage.contains("input_tokens_details")) { + target.cache_read_tokens = nonnegative_int(usage["input_tokens_details"], "cached_tokens"); + } + if (usage.contains("output_tokens_details")) { + target.reasoning_tokens = nonnegative_int(usage["output_tokens_details"], "reasoning_tokens"); + } + target.has_data = true; +} + +bool read_message_text(const Json& item, std::string& text) { + if (text_field(item, "role") != "assistant" || !has_array(item, "content")) return false; + for (const auto& part : item["content"]) { + if (!part.is_object() || !has_string(part, "type")) return false; + const auto type = text_field(part, "type"); + if (type == "output_text") { + if (!has_string(part, "text")) return false; + text += text_field(part, "text"); + } else if (type == "refusal") { + if (!has_string(part, "refusal")) return false; + text += text_field(part, "refusal"); + } else { + return false; + } + } + return !item.contains("phase") || item["phase"].is_null() || item["phase"].is_string(); +} + +bool read_reasoning_text(const Json& item, std::string& text) { + if (!has_array(item, "summary")) return false; + if (item.contains("encrypted_content") && !item["encrypted_content"].is_null() && + !item["encrypted_content"].is_string()) return false; + std::string summary; + for (const auto& part : item["summary"]) { + if (text_field(part, "type") != "summary_text" || !has_string(part, "text")) return false; + summary += text_field(part, "text"); + } + std::string content; + if (item.contains("content") && !item["content"].is_null()) { + if (!item["content"].is_array()) return false; + for (const auto& part : item["content"]) { + if (text_field(part, "type") != "reasoning_text" || !has_string(part, "text")) return false; + content += text_field(part, "text"); + } + } + text += content.empty() ? summary : content; + return true; +} + +bool read_function(const Json& item, ToolCall& call, bool require_complete) { + call.id = text_field(item, "call_id"); + call.function_name = text_field(item, "name"); + call.function_arguments = text_field(item, "arguments"); + if (call.id.empty() || call.function_name.empty() || !has_string(item, "arguments")) return false; + if (require_complete) { + if (item.contains("status") && text_field(item, "status") != "completed") return false; + if (Json::parse(call.function_arguments, nullptr, false).is_discarded()) return false; + } + return true; +} + +bool calls_equal(const std::vector& left, const std::vector& right) { + if (left.size() != right.size()) return false; + for (std::size_t i = 0; i < left.size(); ++i) { + if (left[i].id != right[i].id || + left[i].function_name != right[i].function_name || + left[i].function_arguments != right[i].function_arguments) return false; + } + return true; +} + +bool read_chat_calls(const Json& value, std::vector& calls) { + if (value.is_null()) return true; + if (!value.is_array()) return false; + std::set ids; + for (const auto& item : value) { + if (!item.is_object() || !item.contains("function") || + !item["function"].is_object()) return false; + ToolCall call{text_field(item, "id"), text_field(item["function"], "name"), + text_field(item["function"], "arguments")}; + if (call.id.empty() || call.function_name.empty() || + !has_string(item["function"], "arguments") || !ids.insert(call.id).second) return false; + calls.push_back(std::move(call)); + } + return true; +} + +bool chat_text(const Json& value, std::string& text) { + if (value.is_null()) return true; + if (value.is_string()) { + text = value.get(); + return true; + } + if (!value.is_array()) return false; + for (const auto& part : value) { + const auto type = text_field(part, "type"); + if ((type != "text" && type != "input_text" && type != "output_text") || + !has_string(part, "text")) return false; + text += text_field(part, "text"); + } + return true; +} + +// Replay a native group atomically: never keep its reasoning or a stale call +// while independently synthesizing different canonical assistant content. +Json native_group(const Json& normalized, const ChatMessage* original) { + if (!original || !original->content_parts.is_array()) return {}; + Json items = Json::array(); + for (const auto& part : original->content_parts) { + if (text_field(part, "type") != "openai_responses_item") continue; + if (!part.contains("item") || !part["item"].is_object()) return {}; + const auto& item = part["item"]; + const auto type = text_field(item, "type"); + if (type != "reasoning" && type != "message" && type != "function_call") return {}; + // Storage is disabled, so a bare reasoning ID cannot be resolved later. + if (type == "reasoning" && text_field(item, "encrypted_content").empty()) return {}; + items.push_back(item); + } + if (items.empty()) return {}; + const auto parsed = parse_openai_responses_response({{"status", "completed"}, {"output", items}}); + if (parsed.provider_error.has_error()) return {}; + std::string normalized_text; + if (!chat_text(normalized.contains("content") ? normalized["content"] : Json(), + normalized_text)) return {}; + std::vector normalized_calls; + std::vector original_calls; + if (!read_chat_calls(normalized.contains("tool_calls") ? normalized["tool_calls"] : Json(), + normalized_calls) || + !read_chat_calls(original->tool_calls, original_calls)) return {}; + if (parsed.content != normalized_text || parsed.content != original->content || + !calls_equal(parsed.tool_calls, normalized_calls) || + !calls_equal(parsed.tool_calls, original_calls)) return {}; + return items; +} + +Json convert_content(const Json& source, std::string& error) { + if (source.is_string()) return source; + if (source.is_null()) return ""; + if (!source.is_array()) { + error = "message content must be a string or array"; + return {}; + } + Json parts = Json::array(); + for (const auto& part : source) { + const auto type = text_field(part, "type"); + if (type == "text" || type == "input_text" || type == "output_text") { + if (!has_string(part, "text")) { + error = "text content is missing text"; + return {}; + } + parts.push_back({{"type", "input_text"}, {"text", part["text"]}}); + } else if (type == "image_url" || type == "input_image") { + std::string url = text_field(part, "image_url"); + std::string detail = text_field(part, "detail"); + if (part.contains("image_url") && part["image_url"].is_object()) { + url = text_field(part["image_url"], "url"); + detail = text_field(part["image_url"], "detail"); + } + if (url.empty()) { + error = "image content is missing image_url"; + return {}; + } + parts.push_back({{"type", "input_image"}, {"image_url", url}, + {"detail", detail.empty() ? "auto" : detail}}); + } else { + error = "unsupported message content type: " + type; + return {}; + } + } + return parts; +} + +} // namespace + +Json build_openai_responses_request(const Json& chat_body, + const std::vector* original_messages, + std::string* error) { + std::string local_error; + if (error) error->clear(); + const auto reject = [&](const std::string& message) -> Json { + if (error) *error = message; + return {}; + }; + if (!has_array(chat_body, "messages") || chat_body["messages"].empty()) { + return reject("messages must be a non-empty array"); + } + if (text_field(chat_body, "model").empty()) return reject("model is required"); + Json request{{"model", chat_body["model"]}, {"input", Json::array()}, + {"store", false}, {"include", Json::array({"reasoning.encrypted_content"})}, + {"stream", chat_body.contains("stream") && chat_body["stream"] == true}}; + std::vector assistants; + if (original_messages) { + for (const auto& message : *original_messages) { + if (message.role == "assistant") assistants.push_back(&message); + } + } + // A repaired history can drop assistant rows. Disable ordinal replay when + // counts differ instead of attaching another turn's opaque state. + const auto assistant_count = std::count_if(chat_body["messages"].begin(), + chat_body["messages"].end(), [](const Json& message) { + return text_field(message, "role") == "assistant"; + }); + if (assistants.size() != static_cast(assistant_count)) assistants.clear(); + std::size_t assistant_index = 0; + for (const auto& message : chat_body["messages"]) { + const auto role = text_field(message, "role"); + if (role == "tool") { + const auto id = text_field(message, "tool_call_id"); + if (id.empty()) return reject("tool message is missing tool_call_id"); + auto output = convert_content(message.contains("content") ? message["content"] : Json(), + local_error); + if (!local_error.empty()) return reject(local_error); + request["input"].push_back({{"type", "function_call_output"}, + {"call_id", id}, {"output", std::move(output)}}); + continue; + } + if (role != "system" && role != "developer" && role != "user" && role != "assistant") { + return reject("unsupported message role: " + role); + } + if (role == "assistant") { + const auto native = native_group(message, assistant_index < assistants.size() + ? assistants[assistant_index] : nullptr); + ++assistant_index; + if (native.is_array()) { + for (const auto& item : native) request["input"].push_back(item); + continue; + } + } + auto content = convert_content(message.contains("content") ? message["content"] : Json(), + local_error); + if (!local_error.empty()) return reject(local_error); + const bool has_content = content.is_string() + ? !content.get_ref().empty() : !content.empty(); + if (has_content) { + request["input"].push_back({{"type", "message"}, {"role", role}, + {"content", std::move(content)}}); + } + if (role == "assistant" && message.contains("tool_calls")) { + std::vector calls; + if (!read_chat_calls(message["tool_calls"], calls)) return reject("malformed assistant tool calls"); + for (const auto& call : calls) { + request["input"].push_back({{"type", "function_call"}, {"call_id", call.id}, + {"name", call.function_name}, {"arguments", call.function_arguments}}); + } + } + } + if (request["input"].empty()) return reject("messages do not contain sendable input"); + if (chat_body.contains("tools")) { + if (!chat_body["tools"].is_array()) return reject("tools must be an array"); + request["tools"] = Json::array(); + for (const auto& tool : chat_body["tools"]) { + if (text_field(tool, "type") != "function" || !tool.contains("function") || + !tool["function"].is_object() || text_field(tool["function"], "name").empty()) { + return reject("only function tools are supported"); + } + auto flattened = tool["function"]; + flattened["type"] = "function"; + flattened["strict"] = false; + request["tools"].push_back(std::move(flattened)); + } + } + if (chat_body.contains("max_completion_tokens")) { + request["max_output_tokens"] = chat_body["max_completion_tokens"]; + } else if (chat_body.contains("max_tokens")) { + request["max_output_tokens"] = chat_body["max_tokens"]; + } + for (const char* key : {"temperature", "top_p", "parallel_tool_calls", "metadata", + "service_tier", "prompt_cache_key", "prompt_cache_retention", + "safety_identifier", "reasoning"}) { + if (chat_body.contains(key)) request[key] = chat_body[key]; + } + if (chat_body.contains("reasoning_effort")) { + if (request.contains("reasoning") && !request["reasoning"].is_object()) { + return reject("reasoning must be an object"); + } + request["reasoning"]["effort"] = chat_body["reasoning_effort"]; + } + if (chat_body.contains("tool_choice")) { + const auto& choice = chat_body["tool_choice"]; + if (choice.is_string()) { + request["tool_choice"] = choice; + } else if (text_field(choice, "type") == "function" && choice.contains("function") && + !text_field(choice["function"], "name").empty()) { + request["tool_choice"] = {{"type", "function"}, {"name", choice["function"]["name"]}}; + } else { + return reject("unsupported tool_choice"); + } + } + if (chat_body.contains("response_format")) { + auto format = chat_body["response_format"]; + if (text_field(format, "type") == "json_schema" && format.contains("json_schema") && + format["json_schema"].is_object()) { + format = format["json_schema"]; + format["type"] = "json_schema"; + } + request["text"]["format"] = std::move(format); + } + return request; +} + +ChatResponse parse_openai_responses_response(const Json& envelope) { + if (!envelope.is_object()) return invalid_response(envelope, "Invalid OpenAI Responses envelope"); + const auto status = text_field(envelope, "status"); + if ((envelope.contains("error") && !envelope["error"].is_null()) || + status == "failed" || status == "cancelled") { + return invalid_response(envelope, "OpenAI response failed", ProviderErrorKind::Unknown); + } + if ((status != "completed" && status != "incomplete") || !has_array(envelope, "output")) { + return invalid_response(envelope, "OpenAI response is missing terminal status or output"); + } + ChatResponse result; + std::set calls; + std::set item_ids; + for (const auto& item : envelope["output"]) { + if (!item.is_object() || !has_string(item, "type")) { + return invalid_response(envelope, "Malformed OpenAI output item"); + } + const auto id = text_field(item, "id"); + if (!id.empty() && !item_ids.insert(id).second) { + return invalid_response(envelope, "Duplicate OpenAI output item ID"); + } + const auto type = text_field(item, "type"); + if (type == "message") { + if (!read_message_text(item, result.content)) { + return invalid_response(envelope, "Malformed OpenAI message output"); + } + } else if (type == "reasoning") { + if (!read_reasoning_text(item, result.reasoning_content)) { + return invalid_response(envelope, "Malformed OpenAI reasoning output"); + } + } else if (type == "function_call") { + // Partial calls are neither executable nor replayable. They may + // legitimately lack a name, call_id, or complete JSON arguments. + if (status == "incomplete") continue; + ToolCall call; + if (!read_function(item, call, true) || !calls.insert(call.id).second) { + return invalid_response(envelope, "Malformed or duplicate OpenAI function call"); + } + result.tool_calls.push_back(std::move(call)); + } else { + // Future output types are never turned into local executable calls. + continue; + } + result.content_parts.push_back({{"type", "openai_responses_item"}, {"item", item}}); + } + if (envelope.contains("usage")) merge_usage(result.usage, envelope["usage"]); + if (status == "incomplete") { + const auto reason = envelope.contains("incomplete_details") + ? text_field(envelope["incomplete_details"], "reason") : std::string{}; + result.finish_reason = reason == "content_filter" ? "content_filter" : "length"; + } else { + result.finish_reason = result.tool_calls.empty() ? "stop" : "tool_calls"; + } + return result; +} + +bool OpenAiResponsesStreamParser::identify_output(const Json& event, const Json* item, int& index) { + if (!valid_index(event, "output_index")) return false; + if ((event.contains("item_id") && !has_string(event, "item_id")) || + (item && item->contains("id") && !has_string(*item, "id"))) return false; + index = event["output_index"].get(); + const auto id = item ? text_field(*item, "id") : text_field(event, "item_id"); + if (!id.empty()) { + const auto known = index_by_id_.find(id); + if (known != index_by_id_.end() && known->second != index) return false; + const auto previous = output_.find(index); + if (previous != output_.end()) { + const auto old_id = text_field(previous->second.item, "id"); + if (!old_id.empty() && old_id != id) return false; + } + index_by_id_[id] = index; + output_[index].item["id"] = id; + } + return true; +} + +std::vector OpenAiResponsesStreamParser::fail( + const Json& event, ProviderErrorKind kind, const std::string& message, bool retryable) { + if (terminal_) return {}; + accumulated_.finish_reason = "error"; + accumulated_.tool_calls.clear(); + accumulated_.content_parts = Json::array(); + accumulated_.provider_error = response_error(event, kind, message); + accumulated_.provider_error.retryable = accumulated_.provider_error.retryable || retryable; + StreamEvent failure; + failure.type = StreamEventType::Error; + failure.error = accumulated_.provider_error.display_message; + failure.provider_error = accumulated_.provider_error; + terminal_ = true; + return {std::move(failure)}; +} + +std::vector OpenAiResponsesStreamParser::complete(const Json& event) { + if (!event.contains("response") || !event["response"].is_object()) { + return fail(event, ProviderErrorKind::MalformedSse, "Terminal OpenAI event is missing response"); + } + auto envelope = event["response"]; + const std::string expected_status = text_field(event, "type") == "response.completed" ? "completed" : "incomplete"; + if (text_field(envelope, "status") != expected_status || !has_array(envelope, "output")) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed terminal OpenAI response"); + } + for (const auto& entry : output_) { + const auto index = static_cast(entry.first); + const auto& state = entry.second; + if (index >= envelope["output"].size() || !envelope["output"][index].is_object()) { + return fail(event, ProviderErrorKind::MalformedSse, "Terminal response omitted a streamed output item"); + } + auto& final_item = envelope["output"][index]; + const auto id = text_field(state.item, "id"); + const auto type = text_field(state.item, "type"); + if ((!id.empty() && !text_field(final_item, "id").empty() && id != text_field(final_item, "id")) || + (!type.empty() && type != text_field(final_item, "type"))) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI output item changed identity"); + } + if (expected_status == "completed" && state.arguments_seen && + !prefix_of(state.arguments, text_field(final_item, "arguments"))) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI function arguments changed during streaming"); + } + if (state.done) { + // Some gateways omit opaque fields from the terminal snapshot. + // Only output_item.done is a safe fallback; added can be partial. + for (const char* key : {"encrypted_content", "phase"}) { + if ((!final_item.contains(key) || final_item[key].is_null()) && state.item.contains(key)) { + final_item[key] = state.item[key]; + } + } + if (expected_status == "completed" && type == "function_call" && + (text_field(state.item, "call_id") != text_field(final_item, "call_id") || + text_field(state.item, "name") != text_field(final_item, "name"))) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI function call changed identity"); + } + } + } + auto final = parse_openai_responses_response(envelope); + if (final.provider_error.has_error()) { + return fail(event, final.provider_error.kind == ProviderErrorKind::MalformedJson + ? ProviderErrorKind::MalformedSse : final.provider_error.kind, + final.provider_error.display_message); + } + if (!prefix_of(accumulated_.content, final.content) || + !prefix_of(accumulated_.reasoning_content, final.reasoning_content)) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI terminal output disagrees with streamed text"); + } + std::vector events; + const auto emit_suffix = [&](StreamEventType type, const std::string& previous, const std::string& complete_text) { + if (complete_text.size() <= previous.size()) return; + StreamEvent delta; + delta.type = type; + delta.content = complete_text.substr(previous.size()); + events.push_back(std::move(delta)); + }; + emit_suffix(StreamEventType::Delta, accumulated_.content, final.content); + emit_suffix(StreamEventType::ReasoningDelta, accumulated_.reasoning_content, final.reasoning_content); + for (std::size_t i = 0; i < envelope["output"].size(); ++i) { + if (expected_status != "completed" || + text_field(envelope["output"][i], "type") != "function_call") continue; + StreamEvent call; + call.type = StreamEventType::ToolCall; + read_function(envelope["output"][i], call.tool_call, true); + call.tool_index = static_cast(i); + events.push_back(std::move(call)); + } + if (final.usage.has_data) { + StreamEvent usage; + usage.type = StreamEventType::Usage; + usage.usage = final.usage; + events.push_back(std::move(usage)); + } + accumulated_ = std::move(final); + StreamEvent done; + done.type = StreamEventType::Done; + done.finish_reason = accumulated_.finish_reason; + done.content_parts = accumulated_.content_parts; + events.push_back(std::move(done)); + terminal_ = true; + return events; +} + +std::vector OpenAiResponsesStreamParser::consume(const Json& event) { + if (terminal_) return {}; + if (!has_string(event, "type") || text_field(event, "type").empty()) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed OpenAI SSE event type"); + } + const auto type = text_field(event, "type"); + if (type == "error" || type == "response.failed" || type == "response.cancelled") { + return fail(event, ProviderErrorKind::Unknown, "OpenAI response failed"); + } + if (type == "response.completed" || type == "response.incomplete") return complete(event); + if (type == "response.created" || type == "response.in_progress" || type == "response.queued") { + if (!event.contains("response") || !event["response"].is_object()) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI lifecycle event is missing response"); + } + return {}; + } + const bool text_delta = type == "response.output_text.delta" || type == "response.refusal.delta"; + const bool reasoning_delta = type == "response.reasoning_summary_text.delta" || type == "response.reasoning_text.delta"; + if (text_delta || reasoning_delta) { + int index = -1; + if (!has_string(event, "delta") || !identify_output(event, nullptr, index)) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed OpenAI text delta"); + } + const auto delta = text_field(event, "delta"); + if (delta.empty()) return {}; + if (text_delta) accumulated_.content += delta; + else accumulated_.reasoning_content += delta; + StreamEvent result; + result.type = text_delta ? StreamEventType::Delta : StreamEventType::ReasoningDelta; + result.content = delta; + return {std::move(result)}; + } + if (type == "response.output_item.added" || type == "response.output_item.done") { + int index = -1; + if (!event.contains("item") || !event["item"].is_object() || + !has_string(event["item"], "type") || !identify_output(event, &event["item"], index)) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed OpenAI output item event"); + } + auto& state = output_[index]; + const auto& item = event["item"]; + const auto item_type = text_field(item, "type"); + const auto previous_type = text_field(state.item, "type"); + for (const char* key : {"call_id", "name", "arguments", "role", "status"}) { + if (item.contains(key) && !has_string(item, key)) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed OpenAI output item field"); + } + } + if ((!previous_type.empty() && previous_type != item_type) || + (state.done && state.item != item)) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI output item changed after completion"); + } + if (type == "response.output_item.done") { + if (item_type == "function_call") { + ToolCall call; + if (text_field(item, "status") == "incomplete") { + // output_item.done also closes unfinished items when the + // response runs out of tokens. Await the terminal status; + // no executable call is emitted from this event. + state.arguments = text_field(item, "arguments"); + state.arguments_seen = has_string(item, "arguments"); + } else if (!read_function(item, call, false) || + (state.arguments_seen && !prefix_of(state.arguments, call.function_arguments))) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed completed OpenAI function item"); + } else { + state.arguments = call.function_arguments; + state.arguments_seen = true; + } + } else if (item_type == "message" || item_type == "reasoning") { + std::string ignored; + if (!(item_type == "message" ? read_message_text(item, ignored) : read_reasoning_text(item, ignored))) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed completed OpenAI output item"); + } + } + state.done = true; + } + state.item = item; + return {}; + } + if (type == "response.function_call_arguments.delta" || type == "response.function_call_arguments.done") { + int index = -1; + const auto key = type == "response.function_call_arguments.delta" ? "delta" : "arguments"; + if (!has_string(event, key) || !identify_output(event, nullptr, index)) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed OpenAI function arguments event"); + } + auto& state = output_[index]; + const auto item_type = text_field(state.item, "type"); + if ((!item_type.empty() && item_type != "function_call") || state.done) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI arguments do not belong to an active function call"); + } + state.item["type"] = "function_call"; + const auto value = text_field(event, key); + if (type == "response.function_call_arguments.done") { + if (!prefix_of(state.arguments, value)) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI completed arguments disagree with deltas"); + } + state.arguments = value; + state.arguments_seen = true; + return {}; + } + state.arguments += value; + state.arguments_seen = true; + StreamEvent progress; + progress.type = StreamEventType::ToolCallDelta; + progress.tool_index = index; + progress.tool_call.id = text_field(state.item, "call_id"); + progress.tool_call.function_name = text_field(state.item, "name"); + progress.tool_call_argument_bytes = state.arguments.size(); + return {std::move(progress)}; + } + if (type == "response.output_text.done" || type == "response.refusal.done" || + type == "response.reasoning_summary_text.done" || type == "response.reasoning_text.done") { + int index = -1; + const auto key = type == "response.refusal.done" ? "refusal" : "text"; + if (!has_string(event, key) || !identify_output(event, nullptr, index)) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed OpenAI text completion event"); + } + return {}; + } + if (type == "response.content_part.added" || type == "response.content_part.done" || + type == "response.reasoning_summary_part.added" || type == "response.reasoning_summary_part.done") { + int index = -1; + if (!event.contains("part") || !event["part"].is_object() || + !has_string(event["part"], "type") || !identify_output(event, nullptr, index)) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed OpenAI content part event"); + } + const auto& part = event["part"]; + const auto part_type = text_field(part, "type"); + if (((part_type == "output_text" || part_type == "summary_text" || part_type == "reasoning_text") && + !has_string(part, "text")) || + (part_type == "refusal" && !has_string(part, "refusal"))) { + return fail(event, ProviderErrorKind::MalformedSse, "Malformed OpenAI content part text"); + } + return {}; + } + return {}; // Unknown future events are safe to ignore. +} + +std::vector OpenAiResponsesStreamParser::finish() { + if (terminal_) return {}; + return fail(Json::object(), ProviderErrorKind::MalformedSse, + "OpenAI stream ended before a terminal response event", true); +} + +} // namespace acecode diff --git a/src/adapters/provider/openai_responses.hpp b/src/adapters/provider/openai_responses.hpp new file mode 100644 index 000000000..1467bb019 --- /dev/null +++ b/src/adapters/provider/openai_responses.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "llm/llm_provider.hpp" + +#include + +#include +#include +#include + +namespace acecode { + +// The Chat-shaped input has already passed the existing attachment and history +// repair policy. Native output is replayed only when it agrees with that input. +nlohmann::json build_openai_responses_request( + const nlohmann::json& chat_body, + const std::vector* original_messages = nullptr, + std::string* error = nullptr); + +ChatResponse parse_openai_responses_response(const nlohmann::json& envelope); + +class OpenAiResponsesStreamParser { +public: + std::vector consume(const nlohmann::json& event); + std::vector finish(); + + bool terminal() const { return terminal_; } + const ChatResponse& accumulated() const { return accumulated_; } + +private: + struct OutputState { + nlohmann::json item = nlohmann::json::object(); + std::string arguments; + bool arguments_seen = false; + bool done = false; + }; + + bool identify_output(const nlohmann::json& event, + const nlohmann::json* item, + int& index); + std::vector complete(const nlohmann::json& event); + std::vector fail(const nlohmann::json& event, + ProviderErrorKind kind, + const std::string& message, + bool retryable = false); + + ChatResponse accumulated_; + std::map output_; + std::map index_by_id_; + bool terminal_ = false; +}; + +} // namespace acecode diff --git a/src/adapters/provider/openai_responses_provider.cpp b/src/adapters/provider/openai_responses_provider.cpp new file mode 100644 index 000000000..6cf5c779b --- /dev/null +++ b/src/adapters/provider/openai_responses_provider.cpp @@ -0,0 +1,489 @@ +#include "openai_responses_provider.hpp" + +#include "openai_responses.hpp" +#include "retry_policy.hpp" +#include "stream_diagnostic_capture.hpp" +#include "config/request_headers.hpp" +#include "network/proxy_resolver.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace acecode { +namespace { + +constexpr int kStreamConnectTimeoutCapMs = 15000; + +std::int64_t steady_now_ms() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +std::string ascii_lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return value; +} + +std::string header_value(const cpr::Header& headers, const std::string& key) { + for (const auto& [name, value] : headers) { + if (ascii_lower(name) == key) return value; + } + return {}; +} + +void enrich_error(ProviderErrorInfo& info, const std::string& model, + const cpr::Header& headers) { + info.provider = "openai"; + info.model = model; + for (const char* key : {"x-request-id", "request-id", "x-ms-request-id", "cf-ray"}) { + const auto value = header_value(headers, key); + if (!value.empty()) { + info.request_id = value; + break; + } + } + if (const auto delay = parse_retry_after_ms(header_value(headers, "retry-after"))) { + info.server_retry_after_ms = *delay; + } + if (provider_error_body_has_hard_quota(info.raw_body)) info.retryable = false; +} + +ProviderErrorInfo request_error(ProviderErrorKind kind, int status, + const std::string& model, const std::string& message, + const std::string& raw_body = {}, bool retryable = false, + const cpr::Header& headers = {}) { + ProviderErrorInfo info; + info.kind = kind; + info.status_code = status; + info.display_message = message; + info.raw_body = raw_body; + info.retryable = retryable; + const auto parsed = nlohmann::json::parse(raw_body, nullptr, false); + if (!parsed.is_discarded()) { + info.body_is_json = true; + info.pretty_json = parsed.dump(2); + } + enrich_error(info, model, headers); + if (!raw_body.empty()) { + info.display_message += "\n" + + (info.body_is_json ? info.pretty_json : raw_body); + } + return info; +} + +ProviderErrorKind transport_error_kind(const cpr::Error& error) { + const auto message = ascii_lower(error.message); + return error.code == cpr::ErrorCode::OPERATION_TIMEDOUT || + message.find("timeout") != std::string::npos || + message.find("timed out") != std::string::npos + ? ProviderErrorKind::Timeout : ProviderErrorKind::Network; +} + +ChatResponse failed_response(ProviderErrorInfo info) { + ChatResponse response; + response.content = "[Error] " + info.display_message; + response.finish_reason = "error"; + response.provider_error = std::move(info); + return response; +} + +void emit_error(const StreamCallback& callback, const ProviderErrorInfo& info, + StreamEventType type = StreamEventType::Error) { + StreamEvent event; + event.type = type; + event.provider_error = info; + event.error = info.display_message; + callback(event); +} + +std::optional request_headers(const std::string& api_key, + const std::map& configured, bool stream, + std::string& error) { + const auto resolved = resolve_request_headers(configured, error); + if (!resolved) return std::nullopt; + cpr::Header headers{{"Content-Type", "application/json"}, + {"Accept", stream ? "text/event-stream" : "application/json"}}; + if (stream) headers["Accept-Encoding"] = "identity"; + if (!api_key.empty()) headers["Authorization"] = "Bearer " + api_key; + for (const auto& [name, value] : *resolved) headers[name] = value; + return headers; +} + +bool event_delimiter(const std::string& buffer, std::size_t& position, + std::size_t& length) { + const auto lf = buffer.find("\n\n"); + const auto crlf = buffer.find("\r\n\r\n"); + if (lf == std::string::npos && crlf == std::string::npos) return false; + if (crlf != std::string::npos && (lf == std::string::npos || crlf < lf)) { + position = crlf; + length = 4; + } else { + position = lf; + length = 2; + } + return true; +} + +std::string event_data(const std::string& block) { + std::istringstream input(block); + std::string line; + std::string data; + while (std::getline(input, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.compare(0, 5, "data:") != 0) continue; + const auto start = line.size() > 5 && line[5] == ' ' ? 6u : 5u; + if (!data.empty()) data.push_back('\n'); + data += line.substr(start); + } + return data; +} + +} // namespace + +std::string OpenAiResponsesProvider::request_url() const { + return request_options_.endpoint_mode == "full_url" + ? base_url_ : base_url_ + "/responses"; +} + +bool OpenAiResponsesProvider::supports_prompt_cache_key() const { + const auto url = request_url(); + if (ascii_lower(url.substr(0, 8)) != "https://") return false; + const auto end = url.find('/', 8); + if (end == std::string::npos) return false; + auto authority = ascii_lower(url.substr(8, end - 8)); + if (authority.size() > 4 && authority.compare(authority.size() - 4, 4, ":443") == 0) { + authority.resize(authority.size() - 4); + } + return authority == "api.openai.com" && + url.substr(end, url.find_first_of("?#", end) - end) == "/v1/responses"; +} + +nlohmann::json OpenAiResponsesProvider::responses_body( + const std::vector& messages, const std::vector& tools, + bool stream, bool for_compaction, const ChatRequestOptions* options, + std::string& error) const { + auto normalized = build_request_body(messages, tools, stream, for_compaction, options); + // Only an explicit profile choice disables reasoning. Legacy profiles with + // no toggle keep the model's default, including mandatory-reasoning models. + if (request_options_.reasoning && request_options_.reasoning->supported && + !request_options_.reasoning->mandatory && + request_options_.reasoning->enabled == std::optional(false)) { + normalized["reasoning_effort"] = "none"; + } + if (request_options_.reasoning && request_options_.reasoning->supported && + (request_options_.reasoning->mandatory || + request_options_.reasoning->enabled.value_or(request_options_.reasoning->default_enabled))) { + normalized["reasoning"]["summary"] = "auto"; + } + return build_openai_responses_request(normalized, &messages, &error); +} + +ChatResponse OpenAiResponsesProvider::chat(const std::vector& messages, + const std::vector& tools) { + return chat_impl(messages, tools, nullptr, false, nullptr); +} + +ChatResponse OpenAiResponsesProvider::chat_cancellable( + const std::vector& messages, const std::vector& tools, + const std::atomic* abort_flag) { + return chat_impl(messages, tools, abort_flag, false, nullptr); +} + +ChatResponse OpenAiResponsesProvider::chat_for_compaction( + const std::vector& messages, const std::vector& tools, + const std::atomic* abort_flag) { + return chat_impl(messages, tools, abort_flag, true, nullptr); +} + +ChatResponse OpenAiResponsesProvider::chat_with_options( + const std::vector& messages, const std::vector& tools, + const ChatRequestOptions& options, const std::atomic* abort_flag) { + return chat_impl(messages, tools, abort_flag, options.for_compaction, &options); +} + +ChatResponse OpenAiResponsesProvider::chat_impl( + const std::vector& messages, const std::vector& tools, + const std::atomic* abort_flag, bool for_compaction, + const ChatRequestOptions* options) { + const auto cancelled = [abort_flag] { return abort_flag && abort_flag->load(); }; + const auto cancellation = [&] { + return failed_response(request_error(ProviderErrorKind::UserCancelled, + 0, model_, "Request cancelled")); + }; + if (cancelled()) return cancellation(); + std::string error; + auto body = responses_body(messages, tools, false, for_compaction, options, error); + if (!error.empty()) { + return failed_response(request_error(ProviderErrorKind::Unknown, + 0, model_, "Could not build Responses request: " + error)); + } + const auto headers = request_headers(api_key_, request_headers_, false, error); + if (!headers) { + return failed_response(request_error(ProviderErrorKind::Unknown, 0, model_, error)); + } + const auto url = request_url(); + cpr::Response upstream; + do { + if (cancelled()) return cancellation(); + const auto proxy = network::proxy_options_for(url); + upstream = cpr::Post(cpr::Url{url}, *headers, cpr::Body{body.dump()}, + network::build_ssl_options(proxy), proxy.proxies, proxy.auth, + cpr::Timeout{stream_timeout_ms_}, + cpr::ProgressCallback{[abort_flag](cpr::cpr_off_t, cpr::cpr_off_t, + cpr::cpr_off_t, cpr::cpr_off_t, intptr_t) { + return !abort_flag || !abort_flag->load(); + }}); + } while (!cancelled() && remove_rejected_prompt_cache_key(body, url, + static_cast(upstream.status_code), upstream.text)); + if (cancelled()) return cancellation(); + const auto status = static_cast(upstream.status_code); + if (status == 0 || (upstream.error && status >= 200 && status < 300)) { + return failed_response(request_error(transport_error_kind(upstream.error), + status, model_, "Responses request failed: " + upstream.error.message, + upstream.text, true, upstream.header)); + } + if (status < 200 || status >= 300) { + return failed_response(request_error(ProviderErrorKind::Http, status, + model_, "Responses returned HTTP " + std::to_string(status), + upstream.text, provider_http_error_is_retryable(status, upstream.text), + upstream.header)); + } + try { + auto response = parse_openai_responses_response(nlohmann::json::parse(upstream.text)); + if (response.provider_error.has_error()) { + enrich_error(response.provider_error, model_, upstream.header); + } + return response; + } catch (const nlohmann::json::exception& failure) { + return failed_response(request_error(ProviderErrorKind::MalformedJson, + status, model_, "Could not parse Responses response: " + + std::string(failure.what()), upstream.text, false, upstream.header)); + } +} + +void OpenAiResponsesProvider::chat_stream(const std::vector& messages, + const std::vector& tools, const StreamCallback& callback, + std::atomic* abort_flag) { + stream_impl(messages, tools, callback, abort_flag, nullptr); +} + +void OpenAiResponsesProvider::chat_stream_with_options( + const std::vector& messages, const std::vector& tools, + const ChatRequestOptions& options, const StreamCallback& callback, + std::atomic* abort_flag) { + stream_impl(messages, tools, callback, abort_flag, &options); +} + +void OpenAiResponsesProvider::stream_impl(const std::vector& messages, + const std::vector& tools, const StreamCallback& callback, + std::atomic* abort_flag, const ChatRequestOptions* options) { + // Nullable synchronous borrow: the public call does not return until Post + // and all registered callbacks have finished using this cancellation flag. + const auto cancelled = [abort_flag] { return abort_flag && abort_flag->load(); }; + const auto emit_cancellation = [&] { + emit_error(callback, request_error(ProviderErrorKind::UserCancelled, + 0, model_, "Request cancelled")); + }; + if (cancelled()) { + emit_cancellation(); + return; + } + std::string error; + auto body = responses_body(messages, tools, true, + options && options->for_compaction, options, error); + if (!error.empty()) { + emit_error(callback, request_error(ProviderErrorKind::Unknown, + 0, model_, "Could not build Responses request: " + error)); + return; + } + const auto headers = request_headers(api_key_, request_headers_, true, error); + if (!headers) { + emit_error(callback, request_error(ProviderErrorKind::Unknown, 0, model_, error)); + return; + } + const auto url = request_url(); + std::uint64_t retry_number = 0; + for (;;) { + if (cancelled()) { + emit_cancellation(); + return; + } + OpenAiResponsesStreamParser parser; + StreamDiagnosticCapture capture; + std::string buffer; + ProviderErrorInfo parser_error; + std::vector completion_events; + bool saw_valid_frame = false; + bool emitted_provisional_output = false; + bool malformed_json = false; + std::string parse_error; + std::atomic last_activity{steady_now_ms()}; + std::atomic idle_timeout{false}; + const auto timeout_ms = (std::max)(1, stream_timeout_ms_); + const auto consume = [&parser_error, &completion_events, + &emitted_provisional_output, callback, cancelled](std::vector events) { + for (auto& event : events) { + if (event.type == StreamEventType::Error) { + parser_error = std::move(event.provider_error); + } else if (event.type == StreamEventType::ToolCall || + event.type == StreamEventType::Done || + event.type == StreamEventType::Usage) { + // Never hand executable calls or successful completion to + // the agent until transport integrity is known. + completion_events.push_back(std::move(event)); + } else { + emitted_provisional_output = true; + callback(event); + } + if (cancelled()) break; + } + }; + cpr::Response upstream; + { + // CPR Post invokes these callbacks synchronously. Every reference + // below borrows this attempt's stack state; the nested Session is + // destroyed (releasing its callback closures) before that state, on + // normal return, cancellation, or exception. Session itself is a + // call-local borrow used only for HTTP status; no callback owns it. + cpr::Session session; + const auto write = cpr::WriteCallback{ + [&session, &capture, &buffer, &parser, &parser_error, + &saw_valid_frame, &malformed_json, &parse_error, &last_activity, + &consume, cancelled](const std::string_view bytes, intptr_t) -> bool { + if (cancelled()) return false; + if (!bytes.empty()) last_activity.store(steady_now_ms()); + long status = 0; + curl_easy_getinfo(session.GetCurlHolder()->handle, + CURLINFO_RESPONSE_CODE, &status); + capture.append(bytes, status); + // HTTP errors are JSON/text bodies, not malformed SSE. + if (status < 200 || status >= 300) return true; + buffer.append(bytes.data(), bytes.size()); + std::size_t position = 0; + std::size_t delimiter_length = 0; + while (event_delimiter(buffer, position, delimiter_length)) { + const auto data = event_data(buffer.substr(0, position)); + buffer.erase(0, position + delimiter_length); + if (data.empty()) continue; + if (data == "[DONE]") { + consume(parser.finish()); + } else { + try { + const auto frame = nlohmann::json::parse(data); + saw_valid_frame = true; + consume(parser.consume(frame)); + } catch (const nlohmann::json::exception& failure) { + malformed_json = true; + parse_error = failure.what(); + return false; + } + } + if (cancelled() || parser_error.has_error()) return false; + } + return !cancelled(); + }}; + const auto progress = cpr::ProgressCallback{ + [&last_activity, &idle_timeout, timeout_ms, cancelled]( + cpr::cpr_off_t, cpr::cpr_off_t, cpr::cpr_off_t, + cpr::cpr_off_t, intptr_t) { + if (cancelled()) return false; + if (steady_now_ms() - last_activity.load() >= timeout_ms) { + idle_timeout.store(true); + return false; + } + return true; + }}; + const auto proxy = network::proxy_options_for(url); + session.SetOption(cpr::Url{url}); + session.SetOption(*headers); + session.SetOption(cpr::Body{body.dump()}); + session.SetOption(cpr::ConnectTimeout{(std::min)(timeout_ms, kStreamConnectTimeoutCapMs)}); + session.SetOption(network::build_ssl_options(proxy)); + session.SetOption(proxy.proxies); + session.SetOption(proxy.auth); + session.SetOption(write); + session.SetOption(progress); + upstream = session.Post(); + } + if (cancelled()) { + emit_cancellation(); + return; + } + const auto status = static_cast(upstream.status_code); + const auto raw_body = upstream.text.empty() ? capture.str() : upstream.text; + ProviderErrorInfo failure; + if (status != 0 && (status < 200 || status >= 300)) { + failure = request_error(ProviderErrorKind::Http, status, model_, + "Responses returned HTTP " + std::to_string(status), raw_body, + provider_http_error_is_retryable(status, raw_body), upstream.header); + } else if (parser_error.has_error()) { + failure = std::move(parser_error); + enrich_error(failure, model_, upstream.header); + } else if (malformed_json) { + failure = request_error(saw_valid_frame ? ProviderErrorKind::MalformedSse : + ProviderErrorKind::MalformedJson, status, model_, + "Could not parse Responses SSE event: " + parse_error, + raw_body, saw_valid_frame && !parser.terminal(), upstream.header); + } else if (idle_timeout.load() || status == 0 || upstream.error) { + failure = request_error(idle_timeout.load() ? ProviderErrorKind::Timeout : + transport_error_kind(upstream.error), status, model_, + idle_timeout.load() ? "Responses stream idle timeout" : + "Responses stream failed: " + upstream.error.message, + raw_body, true, upstream.header); + } else { + consume(parser.finish()); + if (parser_error.has_error()) { + failure = std::move(parser_error); + enrich_error(failure, model_, upstream.header); + } else { + for (const auto& event : completion_events) { + if (cancelled()) { + emit_cancellation(); + return; + } + callback(event); + } + return; + } + } + // finish() has no event payload; retain the bounded stream capture so + // a truncated response does not produce an unhelpful empty object. + if (failure.raw_body.empty() || failure.raw_body == "{}") { + failure.raw_body = raw_body; + failure.body_is_json = false; + failure.pretty_json.clear(); + } + if (!emitted_provisional_output && completion_events.empty() && + remove_rejected_prompt_cache_key(body, url, + failure.status_code, failure.raw_body)) continue; + if (!failure.retryable) { + failure.retry_attempt = saturating_retry_attempt(retry_number); + emit_error(callback, failure); + return; + } + if (retry_number < (std::numeric_limits::max)()) ++retry_number; + const auto server_delay = failure.server_retry_after_ms >= 0 + ? std::optional(failure.server_retry_after_ms) : std::nullopt; + const auto delay = provider_retry_delay_ms(retry_number, server_delay, + provider_retry_max_delay_ms(failure.status_code, failure.raw_body)); + failure.retry_attempt = saturating_retry_attempt(retry_number); + failure.retry_max_attempts = -1; + failure.retry_delay_ms = static_cast(delay); + emit_error(callback, failure, StreamEventType::Retry); + if (wait_for_retry(std::chrono::milliseconds(delay), abort_flag)) { + emit_cancellation(); + return; + } + emit_error(callback, failure, StreamEventType::RetryResume); + } +} + +} // namespace acecode diff --git a/src/adapters/provider/openai_responses_provider.hpp b/src/adapters/provider/openai_responses_provider.hpp new file mode 100644 index 000000000..b80477f4e --- /dev/null +++ b/src/adapters/provider/openai_responses_provider.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "openai_provider.hpp" + +namespace acecode { + +// API-key Responses transport. Shared Chat normalization still owns history +// repair and attachment routing; wire encoding and streaming are independent. +class OpenAiResponsesProvider : public OpenAiCompatProvider { +public: + using OpenAiCompatProvider::OpenAiCompatProvider; + + std::string request_url() const override; + + ChatResponse chat(const std::vector& messages, + const std::vector& tools) override; + ChatResponse chat_cancellable(const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag) override; + ChatResponse chat_for_compaction(const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag) override; + ChatResponse chat_with_options(const std::vector& messages, + const std::vector& tools, const ChatRequestOptions& options, + const std::atomic* abort_flag) override; + void chat_stream(const std::vector& messages, + const std::vector& tools, const StreamCallback& callback, + std::atomic* abort_flag = nullptr) override; + void chat_stream_with_options(const std::vector& messages, + const std::vector& tools, const ChatRequestOptions& options, + const StreamCallback& callback, + std::atomic* abort_flag = nullptr) override; + +protected: + bool supports_prompt_cache_key() const override; + bool supports_compaction_tool_choice_none() const override { return true; } + +private: + nlohmann::json responses_body(const std::vector& messages, + const std::vector& tools, bool stream, bool for_compaction, + const ChatRequestOptions* options, std::string& error) const; + ChatResponse chat_impl(const std::vector& messages, + const std::vector& tools, const std::atomic* abort_flag, + bool for_compaction, const ChatRequestOptions* options); + void stream_impl(const std::vector& messages, + const std::vector& tools, const StreamCallback& callback, + std::atomic* abort_flag, const ChatRequestOptions* options); +}; + +} // namespace acecode diff --git a/src/adapters/provider/provider_factory.cpp b/src/adapters/provider/provider_factory.cpp index 838fa4229..7226994c9 100644 --- a/src/adapters/provider/provider_factory.cpp +++ b/src/adapters/provider/provider_factory.cpp @@ -4,6 +4,7 @@ #include "copilot_provider.hpp" #include "grok_provider.hpp" #include "openai_provider.hpp" +#include "openai_responses_provider.hpp" #include "vision_capability.hpp" #include "config/config.hpp" #include "config/model_provider_registry.hpp" @@ -48,11 +49,13 @@ struct EffectiveProviderBuildPlan { ProviderRequestOptions request_options_from_entry(const ModelProfile& entry) { ProviderRequestOptions options; options.endpoint_mode = entry.endpoint_mode.value_or("base_url"); + options.api_protocol = entry.api_protocol.value_or("chat_completions"); options.max_output_tokens = entry.max_output_tokens; options.reasoning = entry.reasoning; if (entry.provider == "anthropic" && entry.reasoning.has_value()) { options.reasoning_protocol = ReasoningWireProtocol::Anthropic; } else if (entry.provider == "openai" && entry.reasoning.has_value() && + options.api_protocol == "chat_completions" && entry.models_dev_provider_id.has_value() && equals_ascii_ci(*entry.models_dev_provider_id, "openrouter")) { options.reasoning_protocol = ReasoningWireProtocol::OpenRouter; @@ -134,6 +137,7 @@ std::string canonical_plan_bytes(const EffectiveProviderBuildPlan& plan) { append_bool(out, plan.uses_request_options); if (plan.uses_request_options) { append_string(out, plan.request_options.endpoint_mode); + append_string(out, plan.request_options.api_protocol); append_optional_int(out, plan.request_options.max_output_tokens); append_u64(out, static_cast( plan.request_options.reasoning_protocol)); @@ -171,6 +175,14 @@ std::optional effective_plan_from_entry( plan.provider_kind = entry.provider; plan.model = entry.model; + if (entry.api_protocol.has_value() && + (entry.provider != "openai" || + (*entry.api_protocol != "chat_completions" && + *entry.api_protocol != "responses"))) { + LOG_WARN("[provider_factory] refusing unsupported API protocol"); + return std::nullopt; + } + if (entry.provider == "openai" || entry.provider == "anthropic") { plan.uses_connection_inputs = true; plan.uses_request_options = true; @@ -239,7 +251,16 @@ std::optional effective_plan_from_entry( std::shared_ptr construct_from_plan( const EffectiveProviderBuildPlan& plan) { std::shared_ptr provider; - if (plan.provider_kind == "openai") { + if (plan.provider_kind == "openai" && + plan.request_options.api_protocol == "responses") { + provider = std::make_shared( + plan.base_url, + plan.api_key, + plan.model, + plan.stream_timeout_ms, + plan.request_headers, + plan.request_options); + } else if (plan.provider_kind == "openai") { provider = std::make_shared( plan.base_url, plan.api_key, diff --git a/src/adapters/provider/provider_request_options.hpp b/src/adapters/provider/provider_request_options.hpp index 81b330314..ff0484aaf 100644 --- a/src/adapters/provider/provider_request_options.hpp +++ b/src/adapters/provider/provider_request_options.hpp @@ -18,6 +18,7 @@ enum class ReasoningWireProtocol { struct ProviderRequestOptions { std::string endpoint_mode = "base_url"; + std::string api_protocol = "chat_completions"; std::optional max_output_tokens; std::optional reasoning; ReasoningWireProtocol reasoning_protocol = ReasoningWireProtocol::None; diff --git a/src/adapters/provider/retry_policy.cpp b/src/adapters/provider/retry_policy.cpp index ad183aa55..801701b18 100644 --- a/src/adapters/provider/retry_policy.cpp +++ b/src/adapters/provider/retry_policy.cpp @@ -111,9 +111,25 @@ bool provider_error_body_has_hard_quota(const std::string& body) { bool provider_http_error_is_retryable(int status_code, const std::string& body) { - if (status_code == 429 && provider_error_body_has_hard_quota(body)) { + const bool hard_quota = provider_error_body_has_hard_quota(body); + if (status_code == 429 && hard_quota) { return false; } + + // 状态码不可信的网关(src/adapters/pa):报文文案优先于状态码。 + // - 写明上下文超限:不重试,哪怕码是 429 / 5xx。重发同一个超大请求不可能 + // 成功,只会把它困在无上限的退避里;不重试才能进压缩 / PA 兜底链。 + // - 写明限流:重试,哪怕码是 400 / 451。限流只与时间窗口有关,等一会儿 + // 原样重发就能过;按 4xx 终止会让一次限流断掉整个回合。硬配额仍终止。 + switch (pa::classify_error_text(body)) { + case pa::FaultKind::ContextOverflow: + return false; + case pa::FaultKind::RateLimited: + return !hard_quota; + default: + break; + } + switch (status_code) { case 408: case 425: @@ -179,26 +195,51 @@ std::optional parse_retry_after_ms( return seconds_until * 1000; } +bool provider_error_is_rate_limited(int status_code, const std::string& body) { + if (provider_error_body_has_hard_quota(body)) return false; + // 与 provider_http_error_is_retryable 同一口径:文案压过状态码。写明超限的 + // 报文即使挂着 429 也不是限流(它根本不会被重试)。 + switch (pa::classify_error_text(body)) { + case pa::FaultKind::RateLimited: + return true; + case pa::FaultKind::ContextOverflow: + return false; + default: + break; + } + return status_code == 429; +} + +std::int64_t provider_retry_max_delay_ms(int status_code, const std::string& body) { + return provider_error_is_rate_limited(status_code, body) + ? kProviderRateLimitRetryMaxDelayMs + : kProviderRetryMaxDelayMs; +} + std::int64_t provider_retry_delay_ms( std::uint64_t retry_number, - std::optional server_delay_ms) { + std::optional server_delay_ms, + std::int64_t max_delay_ms) { if (server_delay_ms.has_value() && *server_delay_ms >= 0) { + // 服务端明确给的等待时长照办(封顶 20 分钟),不受本地退避上限约束: + // 限流网关说「10 分钟后再来」时按 1 分钟去探只会多吃几次拒绝。 return (std::min)(*server_delay_ms, kProviderRetryMaxDelayMs); } + const std::int64_t cap = (std::max)( + kProviderRetryBaseDelayMs, + (std::min)(max_delay_ms, kProviderRetryMaxDelayMs)); std::int64_t delay = kProviderRetryBaseDelayMs; const std::uint64_t doublings = retry_number > 0 ? (std::min)(retry_number - 1, 63) : 0; - for (std::uint64_t i = 0; - i < doublings && delay < kProviderRetryMaxDelayMs; - ++i) { - if (delay > kProviderRetryMaxDelayMs / 2) { - delay = kProviderRetryMaxDelayMs; + for (std::uint64_t i = 0; i < doublings && delay < cap; ++i) { + if (delay > cap / 2) { + delay = cap; break; } delay *= 2; } - return (std::min)(delay, kProviderRetryMaxDelayMs); + return (std::min)(delay, cap); } int saturating_retry_attempt(std::uint64_t retry_number) { diff --git a/src/adapters/provider/retry_policy.hpp b/src/adapters/provider/retry_policy.hpp index f789b5677..f23deed89 100644 --- a/src/adapters/provider/retry_policy.hpp +++ b/src/adapters/provider/retry_policy.hpp @@ -17,26 +17,44 @@ namespace acecode { constexpr std::int64_t kProviderRetryBaseDelayMs = 1000; constexpr std::int64_t kProviderRetryMaxDelayMs = 20 * 60 * 1000; +// 限流(429 或按文案判定的限流报文)的本地退避上限。限流按固定时间窗口复位 +// (实测网关是「100 次 / 10 分钟」),翻倍退避到 20 分钟一次会在窗口早已复位之后 +// 还白等十几分钟;每分钟探一次最多多发几个会被秒拒的请求,代价可以忽略。 +// 服务端 Retry-After 给出的时长不受这个上限约束,仍以 20 分钟封顶。 +constexpr std::int64_t kProviderRateLimitRetryMaxDelayMs = 60 * 1000; // Exact billing/quota codes make an otherwise retryable 429 terminal. bool provider_error_body_has_hard_quota(const std::string& body); // Shared allowlist for streaming and non-streaming provider requests. +// 状态码不可信的网关(src/adapters/pa)按报文文案兜底:写明上下文超限的一律 +// 不重试(要走压缩 / 兜底链,重发同一个超大请求没有意义),写明限流的一律 +// 重试(与时间窗口有关、与请求大小无关)。 bool provider_http_error_is_retryable(int status_code, const std::string& body); +// 这条错误是否属于限流:HTTP 429,或报文文案按 PA 适配判定为限流。进度文案与 +// 退避上限都按它分支;硬配额(额度用完)不算,那是终止性的。 +bool provider_error_is_rate_limited(int status_code, const std::string& body); + +// 本地退避的上限:限流用 kProviderRateLimitRetryMaxDelayMs,其它用 +// kProviderRetryMaxDelayMs。 +std::int64_t provider_retry_max_delay_ms(int status_code, const std::string& body); + // Parses delta-seconds and HTTP-date Retry-After values. Invalid, past, or // overflowing values return nullopt so callers can use local backoff. std::optional parse_retry_after_ms( const std::string& value, std::time_t now = std::time(nullptr)); -// retry_number is one-based: 1s, 2s, 4s, ... and then 20m forever. -// A valid server delay replaces that attempt's local delay; both paths cap at -// twenty minutes. +// retry_number is one-based: 1s, 2s, 4s, ... and then max_delay_ms forever +// (default twenty minutes; rate limits pass kProviderRateLimitRetryMaxDelayMs). +// A valid server delay replaces that attempt's local delay and is capped at +// twenty minutes regardless of max_delay_ms. std::int64_t provider_retry_delay_ms( std::uint64_t retry_number, - std::optional server_delay_ms = std::nullopt); + std::optional server_delay_ms = std::nullopt, + std::int64_t max_delay_ms = kProviderRetryMaxDelayMs); int saturating_retry_attempt(std::uint64_t retry_number); diff --git a/src/adapters/tool/tool_executor.cpp b/src/adapters/tool/tool_executor.cpp index ee113f494..a00875ee8 100644 --- a/src/adapters/tool/tool_executor.cpp +++ b/src/adapters/tool/tool_executor.cpp @@ -657,7 +657,7 @@ ChatMessage ToolExecutor::format_assistant_tool_calls(const ChatResponse& respon msg.content = response.content; // Carry reasoning_content forward so the next API call can echo it back. msg.reasoning_content = response.reasoning_content; - const bool has_signed_anthropic_thinking = + const bool has_native_replay_state = response.content_parts.is_array() && std::any_of(response.content_parts.begin(), response.content_parts.end(), [](const nlohmann::json& part) { @@ -669,12 +669,14 @@ ChatMessage ToolExecutor::format_assistant_tool_calls(const ChatResponse& respon part["signature"].is_string()) || (type == "redacted_thinking" && part.contains("data") && - part["data"].is_string()); + part["data"].is_string()) || + (type == "openai_responses_item" && + part.contains("item") && + part["item"].is_object()); }); - if (has_signed_anthropic_thinking) { - // Only Anthropic's provider-owned signed blocks cross this generic - // tool-history boundary. Other providers retain their prior transcript - // behavior. + if (has_native_replay_state) { + // Signed Anthropic blocks and ordered Responses output must survive + // tool execution and persistence for the next stateless model request. msg.content_parts = response.content_parts; } diff --git a/src/apps/web/handlers/models_handler.cpp b/src/apps/web/handlers/models_handler.cpp index 7c231faa5..124ecb413 100644 --- a/src/apps/web/handlers/models_handler.cpp +++ b/src/apps/web/handlers/models_handler.cpp @@ -75,6 +75,9 @@ nlohmann::json entry_to_json(const ModelProfile& entry) { if (!entry.capabilities.empty()) { o["capabilities"] = entry.capabilities; } + if (entry.api_protocol.has_value()) { + o["api_protocol"] = *entry.api_protocol; + } if (entry.endpoint_mode.has_value()) { o["endpoint_mode"] = *entry.endpoint_mode; } @@ -329,6 +332,16 @@ std::optional parse_model_draft(const nlohmann::json& body, } } } + if (body.contains("api_protocol")) { + d.api_protocol_supplied = true; + if (body["api_protocol"].is_null()) { + d.api_protocol.reset(); + } else if (body["api_protocol"].is_string()) { + d.api_protocol = body["api_protocol"].get(); + } else { + err = "field 'api_protocol' must be string or null"; + } + } if (body.contains("endpoint_mode")) { d.endpoint_mode_supplied = true; if (body["endpoint_mode"].is_null()) { diff --git a/src/base/config/config.cpp b/src/base/config/config.cpp index 9026fc7c2..e67bb3594 100644 --- a/src/base/config/config.cpp +++ b/src/base/config/config.cpp @@ -2672,6 +2672,9 @@ nlohmann::json build_config_json(const AppConfig& cfg) { if (!e.capabilities.empty()) { ej["capabilities"] = e.capabilities; } + if (e.api_protocol.has_value()) { + ej["api_protocol"] = *e.api_protocol; + } if (e.endpoint_mode.has_value()) { ej["endpoint_mode"] = *e.endpoint_mode; } diff --git a/src/base/config/saved_models.cpp b/src/base/config/saved_models.cpp index 5c5a0f195..700c6e15a 100644 --- a/src/base/config/saved_models.cpp +++ b/src/base/config/saved_models.cpp @@ -240,6 +240,14 @@ std::optional parse_one_entry(const nlohmann::json& node, std::siz if (node.contains("capabilities")) { e.capabilities = parse_capabilities_array(node["capabilities"]); } + if (node.contains("api_protocol")) { + if (!node["api_protocol"].is_string()) { + err = "saved_models[" + std::to_string(idx) + + "] field 'api_protocol' must be string"; + return std::nullopt; + } + e.api_protocol = node["api_protocol"].get(); + } if (node.contains("endpoint_mode")) { if (!node["endpoint_mode"].is_string()) { err = "saved_models[" + std::to_string(idx) + @@ -458,6 +466,19 @@ bool validate_saved_models(const std::vector& entries, return false; } } + if (e.api_protocol.has_value()) { + if (e.provider != "openai") { + err = "saved_models entry '" + e.name + + "' cannot use api_protocol for this provider"; + return false; + } + if (*e.api_protocol != "chat_completions" && + *e.api_protocol != "responses") { + err = "saved_models entry '" + e.name + + "' has unknown api_protocol"; + return false; + } + } if (e.endpoint_mode.has_value()) { if (*e.endpoint_mode != "base_url" && *e.endpoint_mode != "full_url") { @@ -753,6 +774,7 @@ bool model_profiles_equal(const ModelProfile& left, left.stream_timeout_ms == right.stream_timeout_ms && left.capabilities == right.capabilities && left.endpoint_mode == right.endpoint_mode && + left.api_protocol == right.api_protocol && left.max_output_tokens == right.max_output_tokens && left.capabilities_source == right.capabilities_source && reasoning_equal && diff --git a/src/base/config/saved_models.hpp b/src/base/config/saved_models.hpp index 7bd852724..2663d3c73 100644 --- a/src/base/config/saved_models.hpp +++ b/src/base/config/saved_models.hpp @@ -38,6 +38,8 @@ struct ModelProfile { std::vector capabilities; // 用户声明的能力标签,如 vision/tool_use/web_search // Missing on legacy profiles. New profiles use base_url or full_url. std::optional endpoint_mode; + // OpenAI only; omission preserves the legacy Chat Completions protocol. + std::optional api_protocol; // chat_completions | responses std::optional max_output_tokens; // catalog/manual marks capabilities as authoritative. Missing preserves // the legacy capability interpretation. diff --git a/src/base/config/saved_models_editor.cpp b/src/base/config/saved_models_editor.cpp index a9dd2c66e..9596bc86b 100644 --- a/src/base/config/saved_models_editor.cpp +++ b/src/base/config/saved_models_editor.cpp @@ -31,6 +31,8 @@ const char* to_string(SavedModelEditError e) { return "INVALID_REQUEST_HEADER"; case SavedModelEditError::INVALID_ENDPOINT_MODE: return "INVALID_ENDPOINT_MODE"; + case SavedModelEditError::INVALID_API_PROTOCOL: + return "INVALID_API_PROTOCOL"; case SavedModelEditError::INVALID_MAX_OUTPUT_TOKENS: return "INVALID_MAX_OUTPUT_TOKENS"; case SavedModelEditError::INVALID_CAPABILITIES_SOURCE: @@ -93,6 +95,15 @@ SavedModelEditError validate_draft_shape(const SavedModelDraft& d) { return SavedModelEditError::INVALID_CAPABILITY; } } + if (field_supplied(d.api_protocol_supplied, d.api_protocol.has_value()) && + d.provider != "openai") { + return SavedModelEditError::UNSUPPORTED_MODEL_OPTION; + } + if (d.api_protocol.has_value() && + *d.api_protocol != "chat_completions" && + *d.api_protocol != "responses") { + return SavedModelEditError::INVALID_API_PROTOCOL; + } if (d.endpoint_mode.has_value() && *d.endpoint_mode != "base_url" && *d.endpoint_mode != "full_url") { @@ -150,6 +161,7 @@ void reset_provider_specific_fields(ModelProfile& profile) { profile.api_key.clear(); profile.models_dev_provider_id.reset(); profile.endpoint_mode.reset(); + profile.api_protocol.reset(); profile.max_output_tokens.reset(); profile.capabilities_source.reset(); profile.reasoning.reset(); @@ -191,6 +203,9 @@ ModelProfile merge_profile(const SavedModelDraft& d, if (is_new || d.capabilities_supplied || !d.capabilities.empty()) { profile.capabilities = d.capabilities; } + if (is_new || d.api_protocol_supplied || d.api_protocol.has_value()) { + profile.api_protocol = d.api_protocol; + } if (is_new || d.endpoint_mode_supplied || d.endpoint_mode.has_value()) { profile.endpoint_mode = d.endpoint_mode; } @@ -293,6 +308,15 @@ SavedModelEditError validate_candidate(const ModelProfile& candidate) { candidate.base_url.empty()) { return SavedModelEditError::MISSING_BASE_URL; } + if (candidate.api_protocol.has_value()) { + if (candidate.provider != "openai") { + return SavedModelEditError::UNSUPPORTED_MODEL_OPTION; + } + if (*candidate.api_protocol != "chat_completions" && + *candidate.api_protocol != "responses") { + return SavedModelEditError::INVALID_API_PROTOCOL; + } + } if (candidate.endpoint_mode.has_value() && *candidate.endpoint_mode != "base_url" && *candidate.endpoint_mode != "full_url") { diff --git a/src/base/config/saved_models_editor.hpp b/src/base/config/saved_models_editor.hpp index b8e7e0faf..46f8a05b9 100644 --- a/src/base/config/saved_models_editor.hpp +++ b/src/base/config/saved_models_editor.hpp @@ -35,6 +35,8 @@ struct SavedModelDraft { bool capabilities_supplied = false; std::optional endpoint_mode; bool endpoint_mode_supplied = false; + std::optional api_protocol; + bool api_protocol_supplied = false; std::optional max_output_tokens; bool max_output_tokens_supplied = false; std::optional capabilities_source; @@ -60,6 +62,7 @@ enum class SavedModelEditError { INVALID_CAPABILITY, // capabilities 含空值/控制字符/重复项 INVALID_REQUEST_HEADER, // request_headers 非 openai / 非法 header/template INVALID_ENDPOINT_MODE, + INVALID_API_PROTOCOL, INVALID_MAX_OUTPUT_TOKENS, INVALID_CAPABILITIES_SOURCE, INVALID_REASONING, diff --git a/src/engine/agent/compaction/compact.cpp b/src/engine/agent/compaction/compact.cpp index 0fb362691..dfa5ee06e 100644 --- a/src/engine/agent/compaction/compact.cpp +++ b/src/engine/agent/compaction/compact.cpp @@ -352,7 +352,17 @@ bool is_context_overflow_error(const ProviderErrorInfo& info) { // 状态码本身也不可信,下面这套 HTTP + 标准 code 的判定一条都不命中。 // 认不出的后果不是多报一个错,而是 handle_provider_error 里那条三级恢复链 // (修剪历史重试 → 精简请求档重试)整个不启动。判定收在 src/pa/。 - if (pa::is_context_overflow(info)) return true; + switch (pa::classify(info)) { + case pa::FaultKind::ContextOverflow: + return true; + case pa::FaultKind::RateLimited: + // 写明「频率 / 限流」的报文绝不是超限:把它当超限会把回合拖进压缩与 + // PA 兜底收缩,白丢历史(反馈:内网限速报文让会话一直在压缩)。这里 + // 提前返回,不再让下面的状态码 + 英文 needle 路径有机会误判。 + return false; + default: + break; + } if (info.kind != ProviderErrorKind::Http) return false; if (info.status_code != 400 && info.status_code != 413 && info.status_code != 422) { @@ -570,7 +580,10 @@ CompactResult compact_messages( } const int delay_ms = static_cast( provider_retry_delay_ms( - transient_retries, server_delay)); + transient_retries, server_delay, + provider_retry_max_delay_ms( + response.provider_error.status_code, + response.provider_error.raw_body))); ProviderErrorInfo retry_info = response.provider_error; retry_info.retry_attempt = saturating_retry_attempt(transient_retries); diff --git a/src/engine/agent/compaction/compaction_controller.cpp b/src/engine/agent/compaction/compaction_controller.cpp index 6e2d2a0d2..9927892c0 100644 --- a/src/engine/agent/compaction/compaction_controller.cpp +++ b/src/engine/agent/compaction/compaction_controller.cpp @@ -166,6 +166,19 @@ bool CompactionController::run_auto(const CompactionInputs& inputs) { }, &request_prefix); active_provider.reset(); + if (!result.performed && abort_.raw()) { + // 用户在摘要请求期间点了停止(典型:摘要请求被限流、正在退避等待)。 + // 这不是摘要失败,不能进下面的机械修剪 —— 那会把最旧的历史真丢掉, + // 用户按一次停止就少一段上下文。按取消收尾,回合由 finalizer 标 aborted。 + LOG_WARN("Auto full compact cancelled by user; error=" + + log_truncate(result.error, 500)); + transcript_.emit_transcript_system_message(inputs.session, + "[Auto-compact] " + result.error, + make_compact_notice_metadata(compact_notice_id, "error", false, + {{"error", result.error}, {"cancelled", true}})); + return false; + } + if (!result.performed) { LOG_WARN("Auto full compact failed; error=" + log_truncate(result.error, 500) + diff --git a/src/engine/agent/progress/retry_progress.cpp b/src/engine/agent/progress/retry_progress.cpp index 55c5cbfbe..25e2c9b41 100644 --- a/src/engine/agent/progress/retry_progress.cpp +++ b/src/engine/agent/progress/retry_progress.cpp @@ -1,5 +1,6 @@ #include "retry_progress.hpp" #include "agent/callbacks_slot.hpp" +#include "provider/retry_policy.hpp" #include "session/event_dispatcher.hpp" #include "utils/time.hpp" #include @@ -32,9 +33,17 @@ void RetryProgressReporter::standard(const ProviderErrorInfo& info, bool waiting bool compaction) { RetryProgressText text; text.phase = waiting ? "model_retry" : (compaction ? "compacting" : "model_waiting"); - text.label = waiting - ? (compaction ? "压缩请求暂时不可用,等待重试" : "网络暂时不可用,等待重试") - : (compaction ? "正在重新发起压缩请求" : "正在重新连接模型"); + // 限流单独说明。被限流时显示「网络暂时不可用」会把用户引向排查网络 / 怀疑 + // 上下文被压缩(反馈:内网 100 次 / 10 分钟的限速触发后,用户以为是超限)。 + const bool rate_limited = + provider_error_is_rate_limited(info.status_code, info.raw_body); + if (waiting) { + text.label = rate_limited + ? (compaction ? "压缩请求被服务端限流,等待重试" : "服务端限流,等待重试") + : (compaction ? "压缩请求暂时不可用,等待重试" : "网络暂时不可用,等待重试"); + } else { + text.label = compaction ? "正在重新发起压缩请求" : "正在重新连接模型"; + } text.detail = "第 " + std::to_string(info.retry_attempt) + " 次重试" + (waiting ? "将在 " + std::to_string(info.retry_delay_ms) + " ms 后发起" : std::string{}); diff --git a/src/engine/agent/turn/response_recovery.cpp b/src/engine/agent/turn/response_recovery.cpp index dfcff9b0b..c27f434a6 100644 --- a/src/engine/agent/turn/response_recovery.cpp +++ b/src/engine/agent/turn/response_recovery.cpp @@ -10,6 +10,8 @@ #include "session/system_notice.hpp" #include "utils/logger.hpp" +#include + namespace acecode::agent { using detail::text_tool_call_rejected_persisted_content; using detail::text_tool_call_diagnostic_to_json; @@ -105,7 +107,14 @@ ResponseRecoveryResult ResponseRecovery::resolve( const bool has_content_parts = response.content_parts.is_array() && - !response.content_parts.empty(); + std::any_of(response.content_parts.begin(), response.content_parts.end(), + [](const nlohmann::json& part) { + // Opaque Responses replay state is not a visible answer. + if (!part.is_object()) return true; + const auto type = part.find("type"); + return type == part.end() || !type->is_string() || + *type != "openai_responses_item"; + }); const bool response_is_blank = !has_content_parts && response.content.find_first_not_of(" \t\r\n") == @@ -122,6 +131,7 @@ ResponseRecoveryResult ResponseRecovery::resolve( empty_msg.content = response.content; empty_msg.reasoning_content = response.reasoning_content; + empty_msg.content_parts = response.content_parts; history_.append(empty_msg); if (session_manager_) session_manager_->on_message(empty_msg); diff --git a/tests/agent/agent_loop_compact_events_test.cpp b/tests/agent/agent_loop_compact_events_test.cpp index 920d40c65..4c1d7daa8 100644 --- a/tests/agent/agent_loop_compact_events_test.cpp +++ b/tests/agent/agent_loop_compact_events_test.cpp @@ -47,12 +47,16 @@ class CompactEventProvider : public acecode::LlmProvider { int stream_message_count = 0; std::vector> compact_requests; std::vector> stream_requests; + // 摘要请求进行中的钩子(在 worker 线程上调用):用例用它模拟「用户在压缩 + // 请求期间点了停止」。 + std::function on_compact_request; acecode::ChatResponse chat( const std::vector& messages, const std::vector&) override { ++chat_calls; compact_requests.push_back(messages); + if (on_compact_request) on_compact_request(); for (const auto& message : messages) { if (message.content.find(std::string(128, 'T')) != std::string::npos) { compact_prompt_saw_full_tool_result = true; @@ -923,6 +927,52 @@ TEST(AgentLoopCompactEvents, FailedAutoCompactFallsBackToMechanicalPrune) { request_contains(loop.messages(), "continue after a failed summarization")); } +// 触发场景:自动压缩的摘要请求进行中(典型:被限流、正在退避等待),用户点了停止。 +// 期望行为:按取消收尾 —— 不走机械修剪,最旧的历史一条不丢;压缩通知停在 error +// 阶段,没有「兜底成功」的 warning。 +// 回归背景(2026-10-08 反馈链):摘要请求遇到 429 时界面长时间停在「等待重试」, +// 用户按停止,旧实现把「Compaction cancelled.」当成摘要失败进了机械修剪,每按 +// 一次停止就真丢一段历史 —— 用户看到的就是「上下文一直在被压缩」。 +TEST(AgentLoopCompactEvents, CancelledAutoCompactDoesNotPruneHistory) { + auto provider = std::make_shared(); + provider->fail_compact = true; + acecode::ToolExecutor tools; + acecode::PermissionManager permissions; + const auto cwd = make_temp_cwd("compact_cancel_no_prune"); + std::filesystem::remove_all( + acecode::SessionStorage::get_project_dir(cwd.string())); + acecode::SessionManager session; + session.start_session(cwd.string(), "stub", "model"); + acecode::AgentLoop loop( + acecode_test::AgentLoopFixture::dependencies([&]() -> std::shared_ptr { return provider; }, tools, {}, permissions, &session), + acecode_test::AgentLoopFixture::configuration(cwd.string())); + loop.start(); + loop.set_context_window(4000); + // 与机械修剪用例同样长的历史:修剪有得可丢,才能证明这里没丢。 + add_history(loop, 20); + // 摘要请求发出的那一刻点停止:compact_messages 在响应返回后看到 abort 标记, + // 以「Compaction cancelled.」返回。 + provider->on_compact_request = [&loop] { loop.abort(); }; + + const auto events = wait_for_done(loop, [&] { + loop.submit("cancel while compacting"); + }); + + EXPECT_EQ(provider->chat_calls, 1); + EXPECT_EQ(provider->stream_calls, 0) << "取消后不应再发主请求"; + EXPECT_TRUE(request_contains(loop.messages(), "old assistant 0")) + << "取消不是摘要失败,最旧的历史不能被机械修剪丢掉"; + EXPECT_FALSE(has_system_event(events, "摘要压缩失败")); + const auto notices = compact_notices(events); + ASSERT_EQ(notices.size(), 2u); + EXPECT_EQ(notices[0].stage, "progress"); + EXPECT_EQ(notices[1].stage, "error"); + for (const auto& notice : notices) { + EXPECT_NE(notice.stage, "warning") << "取消不应被报成兜底成功"; + } + session.finalize(); +} + TEST(AgentLoopCompactEvents, FailedAutoCompactIsAtomicAndRetriesOnNextTurn) { auto provider = std::make_shared(); provider->fail_compact = true; diff --git a/tests/agent/agent_loop_pa_rescue_test.cpp b/tests/agent/agent_loop_pa_rescue_test.cpp index 3f8b6e64d..db0ef922a 100644 --- a/tests/agent/agent_loop_pa_rescue_test.cpp +++ b/tests/agent/agent_loop_pa_rescue_test.cpp @@ -443,6 +443,44 @@ TEST(AgentLoopPaRescue, RecoversAgainAfterASuccessInTheSameTurn) { << "两次撞墙各自从头开始一轮兜底"; } +// 触发场景:网关的限流报文「您的请求频率已达到限制 (100次/10分钟)」以终止性错误 +// 到达 AgentLoop(provider 已放弃重试,或旧版 provider 对非 429 码不重试)。 +// 期望行为:它不是上下文超限 —— 不进 PA 兜底(没有「先原样重发」「次收缩」通知)、 +// 不给预算学习器记账(压缩阈值不变)、历史一条不动;按普通错误结束回合。 +// 回归背景(2026-10-08 反馈):内网限速后用户看到会话一直在压缩,怀疑限流被当成 +// 了超限。这条用例钉住:限流报文永远走不进压缩 / 收缩 / 学习三条路。 +TEST(AgentLoopPaRescue, RateLimitErrorNeverEntersRescueOrBudgetLearner) { + RescueWaitGuard wait_guard; + RescueHarness h("pa_rescue_rate_limit_not_overflow"); + add_history(h.loop, 3); + acecode::ProviderErrorInfo rate_limited; + rate_limited.kind = acecode::ProviderErrorKind::Http; + rate_limited.status_code = 429; + rate_limited.display_message = "HTTP 429 from openai model aicoder-pro"; + // 线上实测报文,一字不改(SSE 形式,带 data: 前缀)。 + rate_limited.raw_body = + "data:{\"object\":\"error\",\"message\":\"您的请求频率已达到限制 (100次/10分钟)。" + "当前已使用: 100次。\",\"type\":\"BadRequestError\",\"code\":429}"; + rate_limited.retryable = false; + h.provider->push_error(rate_limited); + h.provider->push_text("never reached in this turn"); + + const auto events = wait_for_done(h.loop, [&] { + h.loop.submit("latest user request"); + }); + + EXPECT_EQ(h.provider->turn_count(), 1) << "限流不是超限,不该触发任何重发"; + EXPECT_TRUE(has_error_event(events)); + EXPECT_FALSE(has_system_event(events, "先原样重发")); + EXPECT_FALSE(has_system_event(events, "次收缩")); + EXPECT_FALSE(has_system_event(events, "压缩阈值下调")); + EXPECT_FALSE(acecode::pa::context_budget().has_observation( + h.provider->name(), h.provider->model())) + << "限流报文不能进上下文预算学习器"; + EXPECT_TRUE(request_contains(h.loop.messages(), "old assistant 0")); + EXPECT_TRUE(request_contains(h.loop.messages(), "old user 0")); +} + // Use a long real wait so passing requires cancellation, not an elapsed timer. TEST(AgentLoopPaRescue, InterruptTurnWakesRescueWaitPromptly) { RescueWaitGuard wait_guard; diff --git a/tests/agent/agent_loop_tool_protocol_names_test.cpp b/tests/agent/agent_loop_tool_protocol_names_test.cpp index d0766bb15..7b468d5a5 100644 --- a/tests/agent/agent_loop_tool_protocol_names_test.cpp +++ b/tests/agent/agent_loop_tool_protocol_names_test.cpp @@ -2,9 +2,13 @@ #include #include "agent/agent_loop.hpp" +#include "agent/request/provider_history.hpp" #include "permissions/permissions.hpp" #include "provider/dsml_tool_call_recovery.hpp" #include "provider/text_tool_call_recovery.hpp" +#include "provider/openai_provider.hpp" +#include "provider/openai_responses.hpp" +#include "session/session_serializer.hpp" #include "test_support/agent/stub_provider.hpp" #include "tool/tool_executor.hpp" #include "llm/tool_protocol_names.hpp" @@ -338,6 +342,87 @@ TEST(AgentLoopToolProtocolNames, fs::remove_all(cwd); } +TEST(AgentLoopToolProtocolNames, ResponsesNativeItemsSurviveToolExecutionAndResume) { + const fs::path cwd = make_protocol_temp_dir(); + ToolProtocolAgentHarness harness(cwd.string()); + const nlohmann::json output = nlohmann::json::array({ + {{"type", "reasoning"}, {"id", "rs_1"}, + {"summary", nlohmann::json::array()}, {"encrypted_content", "opaque-state"}}, + {{"type", "message"}, {"id", "msg_1"}, {"role", "assistant"}, + {"status", "completed"}, {"phase", "commentary"}, + {"content", nlohmann::json::array({ + {{"type", "output_text"}, {"text", "I will write."}, + {"annotations", nlohmann::json::array()}}})}}, + {{"type", "function_call"}, {"id", "fc_1"}, {"call_id", "call_write"}, + {"status", "completed"}, {"name", "write"}, + {"arguments", R"({"value":"from-responses"})"}}, + }); + acecode::OpenAiResponsesStreamParser parser; + acecode_test::ScriptedResponse script; + script.events = parser.consume({ + {"type", "response.completed"}, + {"response", {{"id", "resp_1"}, {"status", "completed"}, {"output", output}}}, + }); + ASSERT_EQ(parser.accumulated().tool_calls.size(), 1u); + harness.provider().push_response(std::move(script)); + harness.provider().push_text("done"); + ASSERT_TRUE(harness.submit_and_wait()); + harness.loop().shutdown(); + EXPECT_EQ(harness.calls(), 1); + EXPECT_EQ(harness.captured_arguments(), R"({"value":"from-responses"})"); + + const auto* internal = find_assistant_call(harness.loop().messages()); + ASSERT_NE(internal, nullptr); + ASSERT_EQ(internal->content_parts.size(), output.size()); + EXPECT_EQ(internal->tool_calls[0]["function"]["name"], "file_write"); + + std::vector resumed; + for (const auto& msg : harness.loop().messages()) { + resumed.push_back(acecode::deserialize_message(acecode::serialize_message(msg))); + } + resumed = acecode::agent::detail::model_facing_provider_messages( + resumed, "responses-resume-test"); + const auto* resumed_call = find_assistant_call(resumed); + ASSERT_NE(resumed_call, nullptr); + EXPECT_EQ(resumed_call->content_parts, internal->content_parts); + EXPECT_EQ(resumed_call->tool_calls[0]["function"]["name"], "write"); + + class Builder : public acecode::OpenAiCompatProvider { + public: + using OpenAiCompatProvider::OpenAiCompatProvider; + using OpenAiCompatProvider::build_request_body; + }; + Builder builder("https://api.example/v1", "test", "test-model"); + std::string error; + const auto body = acecode::build_openai_responses_request( + builder.build_request_body(resumed, harness.provider().tools_for_turn(1)), + &resumed, &error); + ASSERT_TRUE(error.empty()) << error; + int native_calls = 0; + int native_reasoning = 0; + int tool_outputs = 0; + for (const auto& item : body["input"]) { + const auto type = item.value("type", std::string{}); + if (type == "reasoning") { + ++native_reasoning; + EXPECT_EQ(item["encrypted_content"], "opaque-state"); + } else if (type == "function_call") { + ++native_calls; + EXPECT_EQ(item["name"], "write"); + EXPECT_EQ(item["call_id"], "call_write"); + } else if (type == "function_call_output") { + ++tool_outputs; + EXPECT_EQ(item["call_id"], "call_write"); + } else if (item.value("id", std::string{}) == "msg_1") { + EXPECT_EQ(item["phase"], "commentary"); + } + } + EXPECT_EQ(native_reasoning, 1); + EXPECT_EQ(native_calls, 1); + EXPECT_EQ(tool_outputs, 1); + fs::remove_all(cwd); +} + TEST(AgentLoopToolProtocolNames, RecoveredDsmlUsesAliasPermissionAndExecutesExactlyOnce) { const fs::path cwd = make_protocol_temp_dir(); diff --git a/tests/agent/progress/agent_progress_test.cpp b/tests/agent/progress/agent_progress_test.cpp index b040199c9..4c1ab21fd 100644 --- a/tests/agent/progress/agent_progress_test.cpp +++ b/tests/agent/progress/agent_progress_test.cpp @@ -58,6 +58,44 @@ TEST(AgentProgressEmitter, ExactThrottleBoundaryAndForcedUpdateUseInjectedClock) events.unsubscribe(subscription); } +// 触发场景:provider 因 429 限流进入退避等待;对照一条 503 过载。 +// 期望行为:限流的等待文案写明「限流」,过载仍是「网络暂时不可用」;恢复文案不变。 +// 回归背景(2026-10-08 反馈):内网限速触发后界面显示「网络暂时不可用,等待重试」, +// 用户去排查网络、又怀疑是上下文超限被压缩 —— 文案没说清楚是在等限流窗口。 +TEST(RetryProgressReporter, RateLimitedRetryUsesDedicatedLabel) { + acecode::AgentCallbacks callbacks; + acecode::CallbacksSlot callback_slot; + callback_slot.publish(callbacks); + acecode::EventDispatcher events; + auto labels = std::make_shared>(); + auto subscription = events.subscribe([labels](const acecode::SessionEvent& event) { + labels->push_back(event.payload.value("label", std::string{})); + }); + acecode::agent::RetryProgressReporter reporter(callback_slot, events); + + acecode::ProviderErrorInfo rate_limited; + rate_limited.kind = acecode::ProviderErrorKind::Http; + rate_limited.status_code = 429; + rate_limited.retry_attempt = 1; + rate_limited.retry_delay_ms = 1000; + reporter.standard(rate_limited, true, false); + reporter.standard(rate_limited, true, true); + reporter.standard(rate_limited, false, false); + + acecode::ProviderErrorInfo overloaded = rate_limited; + overloaded.status_code = 503; + reporter.standard(overloaded, true, false); + reporter.standard(overloaded, true, true); + + ASSERT_EQ(labels->size(), 5U); + EXPECT_EQ((*labels)[0], "服务端限流,等待重试"); + EXPECT_EQ((*labels)[1], "压缩请求被服务端限流,等待重试"); + EXPECT_EQ((*labels)[2], "正在重新连接模型"); + EXPECT_EQ((*labels)[3], "网络暂时不可用,等待重试"); + EXPECT_EQ((*labels)[4], "压缩请求暂时不可用,等待重试"); + events.unsubscribe(subscription); +} + TEST(RetryProgressReporter, RegularAndCustomProgressKeepCallbackBeforeEvent) { acecode::AgentCallbacks callbacks; acecode::CallbacksSlot callback_slot; diff --git a/tests/agent/turn/turn_recovery_test.cpp b/tests/agent/turn/turn_recovery_test.cpp index c614ce06e..d5f261e85 100644 --- a/tests/agent/turn/turn_recovery_test.cpp +++ b/tests/agent/turn/turn_recovery_test.cpp @@ -117,4 +117,26 @@ TEST_F(TurnRecoveryTest, StructuredOutputIsNotRetriedAsBlankText) { ResponseRecoveryState state; EXPECT_EQ(recovery.resolve(response, state, {}).action, HandleErrorResult::Proceed); EXPECT_TRUE(history.view().empty()); + response.content_parts = nlohmann::json::array({{{"type", 1}}}); + EXPECT_EQ(recovery.resolve(response, state, {}).action, HandleErrorResult::Proceed); +} + +TEST_F(TurnRecoveryTest, ResponsesReasoningOnlyIsRetriedAndOpaqueStateIsPreserved) { + ChatResponse response; + response.finish_reason = "length"; + response.reasoning_content = "Still considering the request."; + response.content_parts = nlohmann::json::array({ + {{"type", "openai_responses_item"}, {"item", { + {"type", "reasoning"}, {"id", "rs_1"}, + {"summary", nlohmann::json::array()}, + {"encrypted_content", "opaque-state"}}}}, + }); + ResponseRecoveryState state; + const auto result = recovery.resolve(response, state, {}); + EXPECT_EQ(result.action, HandleErrorResult::Continue); + EXPECT_EQ(result.finish_status, "empty_response_retry"); + ASSERT_EQ(history.view().size(), 2u); + EXPECT_EQ(history.view()[0].content_parts, response.content_parts); + EXPECT_EQ(history.view()[0].reasoning_content, response.reasoning_content); + EXPECT_TRUE(history.view()[1].metadata.value("empty_response_retry", false)); } diff --git a/tests/config/saved_models_editor_test.cpp b/tests/config/saved_models_editor_test.cpp index a5cc5632e..975d5595e 100644 --- a/tests/config/saved_models_editor_test.cpp +++ b/tests/config/saved_models_editor_test.cpp @@ -749,3 +749,39 @@ TEST(SavedModelsEditor, AdvancedFieldsPersistAndInvalidReasoningIsAtomic) { SavedModelEditError::INVALID_REASONING); EXPECT_EQ(cfg.saved_models[0].reasoning->max_tokens, 8192); } + +// 协议编辑遵循缺省保留、显式清除和切换 Provider 清理的统一草稿语义。 +TEST(SavedModelsEditor, ApiProtocolPreservesClearsAndResetsOnProviderChange) { + AppConfig cfg; + auto draft = good_openai_draft("responses"); + draft.api_protocol = "responses"; + ASSERT_EQ(add_saved_model(cfg, draft), SavedModelEditError::OK); + EXPECT_EQ(cfg.saved_models.front().api_protocol, "responses"); + draft.api_protocol.reset(); + ASSERT_EQ(update_saved_model(cfg, "responses", draft), SavedModelEditError::OK); + EXPECT_EQ(cfg.saved_models.front().api_protocol, "responses"); + draft.api_protocol_supplied = true; + ASSERT_EQ(update_saved_model(cfg, "responses", draft), SavedModelEditError::OK); + EXPECT_FALSE(cfg.saved_models.front().api_protocol.has_value()); + draft.api_protocol = "responses"; + ASSERT_EQ(update_saved_model(cfg, "responses", draft), SavedModelEditError::OK); + auto anthropic = good_anthropic_draft("responses"); + ASSERT_EQ(update_saved_model(cfg, "responses", anthropic), SavedModelEditError::OK); + EXPECT_FALSE(cfg.saved_models.front().api_protocol.has_value()); +} + +// 无效协议和非 OpenAI Provider 的协议字段不得写入模型列表。 +TEST(SavedModelsEditor, ApiProtocolRejectsInvalidOrUnsupportedDrafts) { + AppConfig cfg; + auto draft = good_openai_draft(); + draft.api_protocol = "auto"; + EXPECT_EQ(add_saved_model(cfg, draft), SavedModelEditError::INVALID_API_PROTOCOL); + EXPECT_TRUE(cfg.saved_models.empty()); + auto anthropic = good_anthropic_draft(); + anthropic.api_protocol = "responses"; + EXPECT_EQ(add_saved_model(cfg, anthropic), SavedModelEditError::UNSUPPORTED_MODEL_OPTION); + anthropic.api_protocol.reset(); + anthropic.api_protocol_supplied = true; + EXPECT_EQ(add_saved_model(cfg, anthropic), SavedModelEditError::UNSUPPORTED_MODEL_OPTION); + EXPECT_TRUE(cfg.saved_models.empty()); +} diff --git a/tests/config/saved_models_test.cpp b/tests/config/saved_models_test.cpp index 2c09674aa..8504ee735 100644 --- a/tests/config/saved_models_test.cpp +++ b/tests/config/saved_models_test.cpp @@ -1282,3 +1282,66 @@ TEST(SavedModelsTest, ParseReadonlyFlagDefaultsFalse) { EXPECT_FALSE((*parsed)[0].readonly); EXPECT_TRUE((*parsed)[1].readonly); } + +// Responses 协议只允许 OpenAI 预设显式选择,缺省仍保留旧协议。 +TEST(SavedModelsTest, ApiProtocolParsesAndRejectsUnsupportedValuesAndProviders) { + const nlohmann::json legacy = { + {"name", "responses-test"}, {"provider", "openai"}, + {"model", "model-id"}, {"base_url", "https://api.example/v1"}, + {"api_key", "test-key"}, + }; + std::string error; + auto parsed = parse_saved_models(nlohmann::json::array({legacy}), error); + ASSERT_TRUE(parsed.has_value()) << error; + EXPECT_FALSE(parsed->front().api_protocol.has_value()); + for (const auto* protocol : {"chat_completions", "responses"}) { + auto node = legacy; + node["api_protocol"] = protocol; + parsed = parse_saved_models(nlohmann::json::array({node}), error); + ASSERT_TRUE(parsed.has_value()) << error; + EXPECT_EQ(parsed->front().api_protocol, protocol); + EXPECT_TRUE(validate_saved_models(*parsed, "", error)) << error; + } + auto invalid = legacy; + invalid["api_protocol"] = 42; + EXPECT_FALSE(parse_saved_models(nlohmann::json::array({invalid}), error)); + EXPECT_NE(error.find("api_protocol"), std::string::npos); + auto profile = make_valid_openai_profile("test", "model-id"); + profile.api_protocol = "guess"; + EXPECT_FALSE(validate_saved_models({profile}, "", error)); + profile.api_protocol = "responses"; + for (const auto* provider : {"anthropic", "copilot", "grok", "codex"}) { + profile.provider = provider; + if (profile.provider == "copilot" || profile.provider == "grok") { + profile.api_key.clear(); + profile.base_url.clear(); + } + EXPECT_FALSE(validate_saved_models({profile}, "", error)) << provider; + EXPECT_NE(error.find("api_protocol"), std::string::npos) << error; + } +} + +// 保存、加载和配置变更比较均须保留协议,否则运行中的模型不会更新。 +TEST(SavedModelsTest, ApiProtocolRoundTripsAndParticipatesInEquality) { + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto path = std::filesystem::temp_directory_path() / + ("acecode-responses-profile-" + std::to_string(suffix) + ".json"); + AppConfig cfg; + auto responses = make_valid_openai_profile("responses", "model-id"); + responses.api_protocol = "responses"; + const auto legacy = make_valid_openai_profile("legacy", "model-id"); + cfg.saved_models = {responses, legacy}; + cfg.default_model_name = responses.name; + save_config(cfg, path.string()); + const auto loaded = load_config_from_path(path.string()); + ASSERT_EQ(loaded.saved_models.size(), 2u); + EXPECT_EQ(loaded.saved_models[0].api_protocol, "responses"); + EXPECT_FALSE(loaded.saved_models[1].api_protocol.has_value()); + EXPECT_TRUE(model_profiles_equal(responses, loaded.saved_models[0])); + auto changed = responses; + changed.api_protocol = "chat_completions"; + EXPECT_FALSE(model_profiles_equal(responses, changed)); + EXPECT_FALSE(saved_model_lists_equal({responses}, {changed})); + std::error_code ec; + std::filesystem::remove(path, ec); +} diff --git a/tests/pa/pa_quirks_test.cpp b/tests/pa/pa_quirks_test.cpp index 73addd78d..7840a4bd9 100644 --- a/tests/pa/pa_quirks_test.cpp +++ b/tests/pa/pa_quirks_test.cpp @@ -4,6 +4,8 @@ #include "provider/retry_policy.hpp" #include "pa/pa_quirks.hpp" +#include + namespace { // set_enabled 改的是进程级开关,测试之间必须还原,否则关掉开关的那个用例会 @@ -32,8 +34,105 @@ acecode::ProviderErrorInfo make_pa_overflow_error() { return info; } +// 线上实测报文(2026-10-08 截图),一字不改:限流被网关以 SSE 形式返回,HTTP 429, +// body 带 "data:" 前缀。 +constexpr const char* kObservedRateLimitBody = + "data:{\"object\":\"error\",\"message\":\"您的请求频率已达到限制 (100次/10分钟)。" + "当前已使用: 100次。\",\"type\":\"BadRequestError\",\"code\":429}"; + +acecode::ProviderErrorInfo make_pa_rate_limit_error(int status_code = 429) { + acecode::ProviderErrorInfo info; + info.kind = acecode::ProviderErrorKind::Http; + info.status_code = status_code; + info.display_message = + "HTTP " + std::to_string(status_code) + " from openai model aicoder-pro"; + info.raw_body = kObservedRateLimitBody; + info.body_is_json = false; + return info; +} + } // namespace +// 触发场景:内网网关把「请求频率已达到限制 (100次/10分钟)」以 429 返回。 +// 期望行为:判为 RateLimited;通用超限入口返回 false;重试策略放行。 +// 回归背景(2026-10-08 反馈):内网做了限速后,这条报文被怀疑当成了上下文超限的 +// 中断,会话表现为一直在压缩。把「限流」在 PA 层显式建模,是为了让它永远走不进 +// 压缩 / 兜底收缩 / 预算学习这三条路。 +TEST(PaQuirks, ObservedRateLimitPayloadIsClassifiedAsRateLimited) { + const auto info = make_pa_rate_limit_error(); + EXPECT_EQ(acecode::pa::classify(info), acecode::pa::FaultKind::RateLimited); + EXPECT_TRUE(acecode::pa::is_rate_limited(info.raw_body)); + EXPECT_FALSE(acecode::pa::is_context_overflow(info)); + EXPECT_FALSE(acecode::is_context_overflow_error(info)); + EXPECT_TRUE(acecode::provider_http_error_is_retryable(429, info.raw_body)); + EXPECT_TRUE(acecode::provider_error_is_rate_limited(429, info.raw_body)); +} + +// 触发场景:同一条限流报文,状态码被网关写成 400 / 451(「错误代码不一定靠谱」)。 +// 期望行为:仍按文案判为限流 —— 可重试、不是超限。修复前 4xx 一律终止,一次 +// 限流就断掉整个回合;而 451 本来是额度用完的码,这里只认报文不认码。 +TEST(PaQuirks, RateLimitTextOverridesUnreliableStatusCode) { + for (int status : {400, 451, 500}) { + const auto info = make_pa_rate_limit_error(status); + EXPECT_EQ(acecode::pa::classify(info), acecode::pa::FaultKind::RateLimited) + << status; + EXPECT_FALSE(acecode::is_context_overflow_error(info)) << status; + EXPECT_TRUE(acecode::provider_http_error_is_retryable(status, info.raw_body)) + << status; + EXPECT_TRUE(acecode::provider_error_is_rate_limited(status, info.raw_body)) + << status; + } + // 硬配额(余额 / 额度用完)仍然终止:那不是等一会儿就能过的事。 + const std::string quota_body = + R"({"message":"请求过于频繁,且账户余额不足","code":429})"; + EXPECT_FALSE(acecode::provider_http_error_is_retryable(429, quota_body)); + EXPECT_FALSE(acecode::provider_error_is_rate_limited(429, quota_body)); +} + +// 触发场景:上下文超限报文被网关以 429 / 503 返回(同一个「码不可信」的问题的 +// 另一面)。 +// 期望行为:不重试、判为超限。修复前 429 / 5xx 在状态码白名单里被无上限重试, +// 同一个超大请求每次都被秒拒,回合困在退避里永远进不了压缩 / 兜底链。 +TEST(PaQuirks, OverflowTextIsNeverRetriedEvenWithRetryableStatusCode) { + for (int status : {429, 503}) { + auto info = make_pa_overflow_error(); + info.status_code = status; + EXPECT_FALSE(acecode::provider_http_error_is_retryable(status, info.raw_body)) + << status; + EXPECT_TRUE(acecode::is_context_overflow_error(info)) << status; + EXPECT_FALSE(acecode::provider_error_is_rate_limited(status, info.raw_body)) + << status; + } +} + +// 触发场景:限流措辞与超限 / 瞬时故障措辞同时出现。 +// 期望行为:RateLimited 优先。「频率 / 限流」说的是时间窗口,比「过大」更具体; +// 判成超限的代价是压缩丢历史,判成限流的代价只是多等一会儿。 +TEST(PaQuirks, RateLimitWordingWinsOverOverflowAndTransientWording) { + EXPECT_EQ(acecode::pa::classify_error_text( + "请求频率过高,请求上下文过大,请稍候重试"), + acecode::pa::FaultKind::RateLimited); + EXPECT_EQ(acecode::pa::classify_error_text("服务繁忙,已触发限流"), + acecode::pa::FaultKind::RateLimited); + EXPECT_FALSE(acecode::pa::is_transient_upstream("操作过于频繁,请稍后重试")); +} + +// 触发场景:关掉 PA 适配总开关。 +// 期望行为:退回纯状态码判定 —— 400 + 限流文案终止,429 + 超限文案重试,证明 +// 这层是可摘除的。 +TEST(PaQuirks, DisablingAdapterRestoresStatusDrivenRetryDecisions) { + PaEnabledGuard guard(false); + const auto rate_limit = make_pa_rate_limit_error(400); + EXPECT_EQ(acecode::pa::classify(rate_limit), acecode::pa::FaultKind::None); + EXPECT_FALSE(acecode::provider_http_error_is_retryable(400, rate_limit.raw_body)); + EXPECT_FALSE(acecode::provider_error_is_rate_limited(400, rate_limit.raw_body)); + // 429 本身仍算限流:那是 HTTP 协议语义,不是 PA 适配。 + EXPECT_TRUE(acecode::provider_error_is_rate_limited(429, rate_limit.raw_body)); + + const auto overflow = make_pa_overflow_error(); + EXPECT_TRUE(acecode::provider_http_error_is_retryable(429, overflow.raw_body)); +} + // 触发场景:内网服务端以中文文案 + 非标准 type 报上下文超限。 // 期望行为:PA 层识别为 ContextOverflow。 // 回归背景:通用判定只认英文 needle 与 OpenAI 标准错误码,这条报文两套都不 diff --git a/tests/provider/openai_responses_provider_test.cpp b/tests/provider/openai_responses_provider_test.cpp new file mode 100644 index 000000000..cd4f90263 --- /dev/null +++ b/tests/provider/openai_responses_provider_test.cpp @@ -0,0 +1,601 @@ +#include "provider/openai_responses_provider.hpp" +#include "utils/joining_thread.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace std::chrono_literals; +using namespace acecode; +using Json = nlohmann::json; + +class LocalHttpServer { +public: + explicit LocalHttpServer(const std::function& setup) { + setup(server_); + port_ = server_.bind_to_any_port("127.0.0.1"); + // The host stops and joins its listener before server_ is destroyed. + listener_ = JoiningThread([this] { server_.listen_after_bind(); }); + for (int i = 0; i < 100 && !server_.is_running(); ++i) { + std::this_thread::sleep_for(5ms); + } + } + ~LocalHttpServer() { + server_.stop(); + listener_.join(); + } + std::string url(const std::string& suffix = "/v1") const { + return "http://127.0.0.1:" + std::to_string(port_) + suffix; + } + +private: + httplib::Server server_; + int port_ = 0; + JoiningThread listener_; +}; + +// Exercise per-call cache isolation and explicit field-rejection fallback +// without sending a paid request to the official endpoint. +class CacheResponsesProvider : public OpenAiResponsesProvider { +public: + using OpenAiResponsesProvider::OpenAiResponsesProvider; +protected: + bool supports_prompt_cache_key() const override { return true; } +}; + +ChatMessage user_message(const std::string& content = "hello") { + ChatMessage message; + message.role = "user"; + message.content = content; + return message; +} + +ToolDef lookup_tool() { + return {"lookup", "Look up a city", Json{{"type", "object"}, + {"properties", {{"city", {{"type", "string"}}}}}}}; +} + +Json message_item(const std::string& text = "ok") { + return {{"id", "msg_1"}, {"type", "message"}, {"status", "completed"}, + {"role", "assistant"}, {"content", Json::array({ + {{"type", "output_text"}, {"text", text}, {"annotations", Json::array()}} + })}}; +} + +Json call_item(const std::string& id = "call_1") { + return {{"id", "fc_1"}, {"type", "function_call"}, {"status", "completed"}, + {"call_id", id}, {"name", "lookup"}, {"arguments", "{\"city\":\"Taipei\"}"}}; +} + +Json completed(Json output = Json::array({message_item()})) { + return {{"id", "resp_1"}, {"object", "response"}, {"status", "completed"}, + {"output", std::move(output)}, {"usage", {{"input_tokens", 8}, + {"output_tokens", 3}, {"total_tokens", 11}, + {"input_tokens_details", {{"cached_tokens", 4}}}, + {"output_tokens_details", {{"reasoning_tokens", 2}}}}}}; +} + +std::string frame(const Json& event, const std::string& delimiter = "\n\n") { + return "data: " + event.dump() + delimiter; +} + +std::string completed_stream(const std::string& text = "ok") { + return frame({{"type", "response.completed"}, + {"response", completed(Json::array({message_item(text)}))}}); +} + +std::vector collect(OpenAiResponsesProvider& provider, + std::atomic* abort_flag = nullptr) { + std::vector events; + provider.chat_stream({user_message()}, {lookup_tool()}, + [&](const StreamEvent& event) { events.push_back(event); }, abort_flag); + return events; +} + +int count_events(const std::vector& events, StreamEventType type) { + return static_cast(std::count_if(events.begin(), events.end(), + [type](const StreamEvent& event) { return event.type == type; })); +} + +const StreamEvent* last_event(const std::vector& events, StreamEventType type) { + for (auto it = events.rbegin(); it != events.rend(); ++it) { + if (it->type == type) return &*it; + } + return nullptr; +} + +TEST(OpenAiResponsesProviderTest, SendsResponsesShapeHeadersAndConfiguredLimits) { + Json received; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request& request, + httplib::Response& response) { + received = Json::parse(request.body); + EXPECT_EQ(request.get_header_value("Authorization"), "Bearer override"); + EXPECT_EQ(request.get_header_value("X-Project"), "project-1"); + EXPECT_EQ(request.get_header_value("Accept"), "application/json"); + response.set_content(completed().dump(), "application/json"); + }); + }); + ProviderRequestOptions options; + options.max_output_tokens = 1200; + options.reasoning_protocol = ReasoningWireProtocol::OpenAi; + options.reasoning = ModelReasoningOptions{}; + options.reasoning->supported = true; + options.reasoning->enabled = true; + options.reasoning->effort = "high"; + options.reasoning->supported_efforts = {"low", "high"}; + OpenAiResponsesProvider provider(server.url() + "/", "original", "test-model", + 5000, {{"Authorization", "Bearer override"}, {"X-Project", "project-1"}}, options); + const auto response = provider.chat({user_message()}, {lookup_tool()}); + EXPECT_EQ(provider.name(), "openai"); + EXPECT_EQ(response.content, "ok"); + EXPECT_EQ(response.usage.cache_read_tokens, 4); + EXPECT_EQ(response.usage.reasoning_tokens, 2); + EXPECT_EQ(response.usage.total_tokens, 11); + EXPECT_EQ(received["store"], false); + EXPECT_EQ(received["model"], "test-model"); + EXPECT_EQ(received["max_output_tokens"], 1200); + EXPECT_EQ(received["reasoning"]["effort"], "high"); + EXPECT_EQ(received["reasoning"]["summary"], "auto"); + EXPECT_EQ(received["tools"][0]["strict"], false); + EXPECT_EQ(received["tools"][0]["name"], "lookup"); + EXPECT_FALSE(received.contains("messages")); + EXPECT_FALSE(received.contains("max_tokens")); + EXPECT_FALSE(received.contains("stream_options")); + EXPECT_FALSE(received.contains("previous_response_id")); +} + +TEST(OpenAiResponsesProviderTest, UsesExactFullUrlAndKeepsMissingReasoningToggleDefault) { + Json received; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/deployment/model/responses", [&](const httplib::Request& request, + httplib::Response& response) { + EXPECT_EQ(request.get_param_value("api-version"), "preview"); + received = Json::parse(request.body); + response.set_content(completed().dump(), "application/json"); + }); + }); + ProviderRequestOptions options; + options.endpoint_mode = "full_url"; + options.reasoning = ModelReasoningOptions{}; + options.reasoning->supported = true; + OpenAiResponsesProvider provider(server.url("/deployment/model/responses?api-version=preview"), + "", "test-model", 5000, {}, options); + EXPECT_EQ(provider.chat({user_message()}, {}).content, "ok"); + EXPECT_FALSE(received.contains("reasoning")); + options.reasoning->enabled = false; + provider.reconfigure(server.url("/deployment/model/responses?api-version=preview"), + "", 5000, {}, options); + EXPECT_EQ(provider.chat({user_message()}, {}).content, "ok"); + EXPECT_EQ(received["reasoning"]["effort"], "none"); +} + +TEST(OpenAiResponsesProviderTest, ReplaysEncryptedReasoningAndOriginalToolCallOnNextTurn) { + const Json reasoning{{"id", "rs_1"}, {"type", "reasoning"}, + {"encrypted_content", "opaque-encrypted-state"}, {"summary", Json::array()}}; + std::vector requests; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request& request, + httplib::Response& response) { + requests.push_back(Json::parse(request.body)); + response.set_content((requests.size() == 1 + ? completed(Json::array({reasoning, call_item()})) + : completed()).dump(), "application/json"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + const auto first = provider.chat({user_message()}, {lookup_tool()}); + ASSERT_EQ(first.tool_calls.size(), 1u); + EXPECT_EQ(first.tool_calls[0].id, "call_1"); + ChatMessage assistant; + assistant.role = "assistant"; + assistant.content = first.content; + assistant.content_parts = first.content_parts; + assistant.tool_calls = Json::array({{{"id", first.tool_calls[0].id}, + {"type", "function"}, {"function", {{"name", first.tool_calls[0].function_name}, + {"arguments", first.tool_calls[0].function_arguments}}}}}); + ChatMessage result; + result.role = "tool"; + result.tool_call_id = "call_1"; + result.content = "sunny"; + EXPECT_EQ(provider.chat({user_message(), assistant, result}, {lookup_tool()}).content, "ok"); + ASSERT_EQ(requests.size(), 2u); + const auto& input = requests[1]["input"]; + ASSERT_EQ(input.size(), 4u); + EXPECT_EQ(input[1], reasoning); + EXPECT_EQ(input[2]["type"], "function_call"); + EXPECT_EQ(input[2]["call_id"], "call_1"); + EXPECT_EQ(input[3]["type"], "function_call_output"); + EXPECT_EQ(input[3]["call_id"], "call_1"); + EXPECT_EQ(input[3]["output"], "sunny"); +} + +TEST(OpenAiResponsesProviderTest, OptionsAndCompactionDispatchStayOnResponses) { + std::vector requests; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request& request, + httplib::Response& response) { + requests.push_back(Json::parse(request.body)); + if (requests.back().value("stream", false)) { + response.set_content(completed_stream(), "text/event-stream"); + } else { + response.set_content(completed().dump(), "application/json"); + } + }); + }); + CacheResponsesProvider provider(server.url(), "", "test-model", 5000); + LlmProvider& polymorphic = provider; + polymorphic.set_prompt_cache_key("session-default"); + ChatRequestOptions options; + options.prompt_cache_key = "request-owned"; + options.for_compaction = true; + EXPECT_EQ(polymorphic.chat_for_compaction({user_message()}, {lookup_tool()}, nullptr).content, "ok"); + EXPECT_EQ(polymorphic.chat_with_options({user_message()}, {lookup_tool()}, options, nullptr).content, "ok"); + std::vector events; + polymorphic.chat_stream_with_options({user_message()}, {lookup_tool()}, options, + [&](const StreamEvent& event) { events.push_back(event); }); + ASSERT_EQ(requests.size(), 3u); + EXPECT_EQ(requests[0]["prompt_cache_key"], "session-default"); + EXPECT_EQ(requests[1]["prompt_cache_key"], "request-owned"); + EXPECT_EQ(requests[2]["prompt_cache_key"], "request-owned"); + for (const auto& request : requests) { + EXPECT_EQ(request["tool_choice"], "none"); + EXPECT_EQ(request["tools"][0]["name"], "lookup"); + } + EXPECT_EQ(count_events(events, StreamEventType::Done), 1); + EXPECT_FALSE(polymorphic.supports_native_compaction()); +} + +TEST(OpenAiResponsesProviderTest, PromptCacheFieldRejectionRetriesOnlyOnceAndRemembersScope) { + std::atomic rejected{0}; + std::atomic accepted{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request& request, + httplib::Response& response) { + const auto body = Json::parse(request.body); + if (body.contains("prompt_cache_key")) { + ++rejected; + response.status = 400; + response.set_content(R"({"error":{"param":"prompt_cache_key","code":"unsupported_parameter"}})", + "application/json"); + } else { + ++accepted; + response.set_content(body.value("stream", false) ? completed_stream() : completed().dump(), + body.value("stream", false) ? "text/event-stream" : "application/json"); + } + }); + }); + CacheResponsesProvider provider(server.url(), "", "test-model", 5000); + provider.set_prompt_cache_key("cache-key"); + const auto events = collect(provider); + EXPECT_EQ(count_events(events, StreamEventType::Done), 1); + EXPECT_EQ(provider.chat({user_message()}, {}).content, "ok"); + EXPECT_EQ(rejected.load(), 1); + EXPECT_EQ(accepted.load(), 2); +} + +TEST(OpenAiResponsesProviderTest, ParsesFragmentedCrLfStreamAndFinalToolExactlyOnce) { + const auto call = call_item(); + const auto sse = std::make_shared( + ": heartbeat\r\n\r\n" + + frame({{"type", "response.output_item.added"}, {"output_index", 0}, {"item", call}}, "\r\n\r\n") + + frame({{"type", "response.output_item.done"}, {"output_index", 0}, {"item", call}}, "\r\n\r\n") + + frame({{"type", "response.completed"}, {"response", completed(Json::array({call}))}}, "\r\n\r\n") + + "data: [DONE]\r\n\r\n"); + LocalHttpServer server([sse](httplib::Server& http) { + http.Post("/v1/responses", [sse](const httplib::Request& request, + httplib::Response& response) { + EXPECT_EQ(request.get_header_value("Accept"), "text/event-stream"); + EXPECT_EQ(request.get_header_value("Accept-Encoding"), "identity"); + response.set_chunked_content_provider("text/event-stream", + [sse](std::size_t offset, httplib::DataSink& sink) { + const auto length = (std::min)(std::size_t(13), sse->size() - offset); + sink.write(sse->data() + offset, length); + if (offset + length == sse->size()) sink.done(); + return true; + }); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + const auto events = collect(provider); + EXPECT_EQ(count_events(events, StreamEventType::Error), 0); + EXPECT_EQ(count_events(events, StreamEventType::ToolCall), 1); + EXPECT_EQ(count_events(events, StreamEventType::Usage), 1); + ASSERT_EQ(count_events(events, StreamEventType::Done), 1); + EXPECT_EQ(last_event(events, StreamEventType::Done)->finish_reason, "tool_calls"); +} + +TEST(OpenAiResponsesProviderTest, TruncatedStreamResetsProvisionalOutputAndCallsBeforeRetry) { + std::atomic requests{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + response.set_header("Retry-After", "0"); + if (++requests == 1) { + response.set_content( + frame({{"type", "response.output_text.delta"}, {"item_id", "msg_old"}, + {"output_index", 0}, {"content_index", 0}, {"delta", "old"}}) + + frame({{"type", "response.output_item.done"}, {"output_index", 1}, + {"item", call_item("call_old")}}) + "data: [DONE]\n\n", "text/event-stream"); + } else { + response.set_content(completed_stream("new"), "text/event-stream"); + } + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + std::string visible; + std::vector events; + provider.chat_stream({user_message()}, {lookup_tool()}, [&](const StreamEvent& event) { + events.push_back(event); + if (event.type == StreamEventType::Retry) visible.clear(); + if (event.type == StreamEventType::Delta) visible += event.content; + }); + EXPECT_EQ(requests.load(), 2); + EXPECT_EQ(visible, "new"); + EXPECT_EQ(count_events(events, StreamEventType::Retry), 1); + EXPECT_EQ(count_events(events, StreamEventType::RetryResume), 1); + EXPECT_EQ(count_events(events, StreamEventType::ToolCall), 0); + EXPECT_EQ(count_events(events, StreamEventType::Done), 1); + EXPECT_EQ(count_events(events, StreamEventType::Error), 0); +} + +TEST(OpenAiResponsesProviderTest, HardQuotaAndMalformedJsonAreTerminalWithDiagnostics) { + const std::vector> failures{ + {429, R"({"error":{"code":"insufficient_quota","message":"buy credits"}})"}, + {200, "data: {not-json}\n\n"}, + {200, frame({{"type", "response.failed"}, {"response", { + {"status", "failed"}, {"error", {{"code", "insufficient_quota"}, + {"message", "buy credits"}}}}}})} + }; + for (const auto& [status, body] : failures) { + SCOPED_TRACE(body); + std::atomic requests{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + ++requests; + response.status = status; + response.set_header("X-Request-Id", "request-fixture"); + response.set_content(body, status == 200 ? "text/event-stream" : "application/json"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + const auto events = collect(provider); + ASSERT_EQ(count_events(events, StreamEventType::Error), 1); + EXPECT_EQ(count_events(events, StreamEventType::Retry), 0); + EXPECT_EQ(count_events(events, StreamEventType::Done), 0); + EXPECT_EQ(requests.load(), 1); + const auto& error = last_event(events, StreamEventType::Error)->provider_error; + EXPECT_FALSE(error.retryable); + EXPECT_EQ(error.request_id, "request-fixture"); + EXPECT_EQ(error.provider, "openai"); + EXPECT_EQ(error.model, "test-model"); + EXPECT_FALSE(error.raw_body.empty()); + } +} + +TEST(OpenAiResponsesProviderTest, TransportFailureAfterTerminalStillSuppressesToolsUntilRetry) { + std::atomic requests{0}; + // Shared between the handler and the response content callback. + const auto payload = std::make_shared(frame({ + {"type", "response.completed"}, {"response", completed(Json::array({call_item()}))}})); + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + response.set_header("Retry-After", "0"); + if (++requests == 1) { + // A complete terminal frame followed by premature HTTP EOF. + response.set_content_provider(payload->size() + 16, "text/event-stream", + [payload](std::size_t offset, std::size_t, httplib::DataSink& sink) { + if (offset >= payload->size()) return false; + sink.write(payload->data() + offset, payload->size() - offset); + return false; + }); + } else { + response.set_content(completed_stream("recovered"), "text/event-stream"); + } + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + const auto events = collect(provider); + EXPECT_EQ(requests.load(), 2); + EXPECT_EQ(count_events(events, StreamEventType::Retry), 1); + EXPECT_EQ(count_events(events, StreamEventType::ToolCall), 0); + EXPECT_EQ(count_events(events, StreamEventType::Done), 1); + EXPECT_EQ(count_events(events, StreamEventType::Error), 0); +} + +TEST(OpenAiResponsesProviderTest, HttpRateLimitAndSseServerFailureRetryWithSamePolicy) { + for (const bool sse_failure : {false, true}) { + std::atomic requests{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + response.set_header("Retry-After", "0"); + if (++requests == 1) { + response.status = sse_failure ? 200 : 429; + response.set_content(sse_failure + ? frame({{"type", "response.failed"}, {"response", {{"status", "failed"}, + {"error", {{"code", "server_error"}, {"message", "try again"}}}}}}) + : R"({"error":{"code":"rate_limit_exceeded","message":"try again"}})", + sse_failure ? "text/event-stream" : "application/json"); + } else { + response.set_content(completed_stream(), "text/event-stream"); + } + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + const auto events = collect(provider); + EXPECT_EQ(requests.load(), 2); + EXPECT_EQ(count_events(events, StreamEventType::Retry), 1); + EXPECT_EQ(count_events(events, StreamEventType::Done), 1); + EXPECT_EQ(count_events(events, StreamEventType::Error), 0); + } +} + +TEST(OpenAiResponsesProviderTest, PreCancelledCallsAndUnresolvedHeadersNeverSendRequest) { + std::atomic requests{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + ++requests; + response.set_content(completed().dump(), "application/json"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + std::atomic abort{true}; + EXPECT_EQ(provider.chat_cancellable({user_message()}, {}, &abort).provider_error.kind, + ProviderErrorKind::UserCancelled); + EXPECT_EQ(provider.chat_for_compaction({user_message()}, {}, &abort).provider_error.kind, + ProviderErrorKind::UserCancelled); + EXPECT_EQ(provider.chat_with_options({user_message()}, {}, {}, &abort).provider_error.kind, + ProviderErrorKind::UserCancelled); + const auto cancelled = collect(provider, &abort); + ASSERT_EQ(cancelled.size(), 1u); + EXPECT_EQ(cancelled[0].provider_error.kind, ProviderErrorKind::UserCancelled); + OpenAiResponsesProvider bad_headers(server.url(), "", "test-model", 5000, + {{"X-Project", "${env:ACECODE_RESPONSES_FIXTURE_MISSING_ENV_VARIABLE}"}}); + EXPECT_TRUE(bad_headers.chat({user_message()}, {}).provider_error.has_error()); + EXPECT_EQ(count_events(collect(bad_headers), StreamEventType::Error), 1); + EXPECT_EQ(requests.load(), 0); +} + +TEST(OpenAiResponsesProviderTest, CancellingPartialStreamSuppressesTerminalTools) { + LocalHttpServer server([](httplib::Server& http) { + http.Post("/v1/responses", [](const httplib::Request&, httplib::Response& response) { + response.set_content(frame({{"type", "response.output_text.delta"}, + {"item_id", "msg_1"}, {"output_index", 0}, {"content_index", 0}, {"delta", "hello"}}) + + frame({{"type", "response.completed"}, {"response", completed(Json::array({call_item()}))}}), + "text/event-stream"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + std::atomic abort{false}; + std::vector events; + provider.chat_stream({user_message()}, {lookup_tool()}, [&](const StreamEvent& event) { + events.push_back(event); + if (event.type == StreamEventType::Delta) abort.store(true); + }, &abort); + EXPECT_EQ(count_events(events, StreamEventType::Delta), 1); + EXPECT_EQ(count_events(events, StreamEventType::ToolCall), 0); + EXPECT_EQ(count_events(events, StreamEventType::Done), 0); + ASSERT_EQ(count_events(events, StreamEventType::Error), 1); + EXPECT_EQ(last_event(events, StreamEventType::Error)->provider_error.kind, + ProviderErrorKind::UserCancelled); +} + +TEST(OpenAiResponsesProviderTest, NonStreamingCancellationAndFailureKeepTypedMetadata) { + std::atomic abort{false}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + abort.store(true); + response.status = 429; + response.set_header("Retry-After", "2"); + response.set_header("X-Request-Id", "non-stream-request"); + response.set_content(R"({"error":{"code":"rate_limit_exceeded"}})", "application/json"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + const auto cancelled = provider.chat_cancellable({user_message()}, {}, &abort); + EXPECT_EQ(cancelled.provider_error.kind, ProviderErrorKind::UserCancelled); + const auto response = provider.chat({user_message()}, {}); + EXPECT_EQ(response.finish_reason, "error"); + EXPECT_TRUE(response.provider_error.retryable); + EXPECT_EQ(response.provider_error.server_retry_after_ms, 2000); + EXPECT_EQ(response.provider_error.request_id, "non-stream-request"); +} + +TEST(OpenAiResponsesProviderTest, CancellationDuringRetryWaitStopsFurtherAttempts) { + std::atomic requests{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + ++requests; + response.status = 503; + response.set_header("Retry-After", "120"); + response.set_content("unavailable", "text/plain"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + std::atomic abort{false}; + std::vector events; + provider.chat_stream({user_message()}, {}, [&](const StreamEvent& event) { + events.push_back(event); + if (event.type == StreamEventType::Retry) abort.store(true); + }, &abort); + EXPECT_EQ(requests.load(), 1); + EXPECT_EQ(count_events(events, StreamEventType::RetryResume), 0); + ASSERT_EQ(count_events(events, StreamEventType::Error), 1); + EXPECT_EQ(last_event(events, StreamEventType::Error)->provider_error.kind, + ProviderErrorKind::UserCancelled); +} + +TEST(OpenAiResponsesProviderTest, GatewayErrorMeaningOverridesStatusForRetries) { + struct Case { + int status; + std::string body; + bool retryable; + }; + const std::vector cases{ + {400, R"json({"message":"您的请求频率已达到限制 (100次/10分钟)"})json", true}, + {429, R"({"message":"请求上下文过大"})", false}, + {429, R"({"error":{"code":"insufficient_quota"}})", false}, + }; + for (const auto& sample : cases) { + SCOPED_TRACE(sample.body); + std::atomic requests{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + ++requests; + response.status = sample.status; + response.set_content(sample.body, "application/json"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + std::atomic abort{false}; + std::vector events; + provider.chat_stream({user_message()}, {}, [&](const StreamEvent& event) { + events.push_back(event); + if (event.type == StreamEventType::Retry) abort.store(true); + }, &abort); + EXPECT_EQ(requests.load(), 1); + EXPECT_EQ(count_events(events, StreamEventType::Retry), sample.retryable ? 1 : 0); + EXPECT_EQ(count_events(events, StreamEventType::Done), 0); + ASSERT_EQ(count_events(events, StreamEventType::Error), 1); + EXPECT_EQ(last_event(events, StreamEventType::Error)->provider_error.kind, + sample.retryable ? ProviderErrorKind::UserCancelled : ProviderErrorKind::Http); + } +} + +TEST(OpenAiResponsesProviderTest, IdleTimeoutBeforeFirstBytesIsCancellableAndRetryable) { + std::atomic requests{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + ++requests; + std::this_thread::sleep_for(1200ms); + response.set_content(completed_stream(), "text/event-stream"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 100); + std::atomic abort{false}; + std::vector events; + provider.chat_stream({user_message()}, {}, [&](const StreamEvent& event) { + events.push_back(event); + if (event.type == StreamEventType::Retry) abort.store(true); + }, &abort); + EXPECT_EQ(requests.load(), 1); + ASSERT_EQ(count_events(events, StreamEventType::Retry), 1); + EXPECT_EQ(last_event(events, StreamEventType::Retry)->provider_error.kind, + ProviderErrorKind::Timeout); + EXPECT_EQ(count_events(events, StreamEventType::Done), 0); + ASSERT_EQ(count_events(events, StreamEventType::Error), 1); + EXPECT_EQ(last_event(events, StreamEventType::Error)->provider_error.kind, + ProviderErrorKind::UserCancelled); +} + +} // namespace diff --git a/tests/provider/openai_responses_test.cpp b/tests/provider/openai_responses_test.cpp new file mode 100644 index 000000000..820600711 --- /dev/null +++ b/tests/provider/openai_responses_test.cpp @@ -0,0 +1,422 @@ +#include "provider/openai_responses.hpp" + +#include + +#include +#include +#include +#include + +namespace { + +using Json = nlohmann::json; +using acecode::ChatMessage; +using acecode::OpenAiResponsesStreamParser; +using acecode::StreamEvent; +using acecode::StreamEventType; + +Json chat_body() { + return {{"model", "gpt-6.1-sol"}, {"messages", Json::array({ + Json{{"role", "system"}, {"content", "Be precise."}}, + Json{{"role", "user"}, {"content", "Inspect this repository."}}, + })}}; +} + +Json message_item(const std::string& text, const std::string& id = "msg_1") { + return {{"id", id}, {"type", "message"}, {"role", "assistant"}, + {"status", "completed"}, {"phase", "commentary"}, + {"content", Json::array({Json{{"type", "output_text"}, {"text", text}, + {"annotations", Json::array()}}})}}; +} + +Json reasoning_item(const std::string& text = "plan") { + return {{"id", "rs_1"}, {"type", "reasoning"}, + {"summary", Json::array({Json{{"type", "summary_text"}, {"text", text}}})}, + {"encrypted_content", "complete-cipher"}}; +} + +Json function_item(const std::string& id = "call_1", const std::string& arguments = R"({"path":"a.cpp"})") { + return {{"id", "fc_" + id}, {"type", "function_call"}, {"status", "completed"}, + {"call_id", id}, {"name", "read_file"}, {"arguments", arguments}}; +} + +Json chat_function(const Json& item) { + return {{"id", item["call_id"]}, {"type", "function"}, + {"function", {{"name", item["name"]}, {"arguments", item["arguments"]}}}}; +} + +Json envelope(Json output, const std::string& status = "completed") { + return {{"id", "resp_1"}, {"status", status}, {"output", std::move(output)}}; +} + +Json terminal(Json output, const std::string& status = "completed") { + return {{"type", "response." + status}, {"response", envelope(std::move(output), status)}}; +} + +const StreamEvent* first_event(const std::vector& events, StreamEventType type) { + const auto found = std::find_if(events.begin(), events.end(), [type](const StreamEvent& event) { + return event.type == type; + }); + return found == events.end() ? nullptr : &*found; +} + +std::size_t count_events(const std::vector& events, StreamEventType type) { + return static_cast(std::count_if(events.begin(), events.end(), + [type](const StreamEvent& event) { return event.type == type; })); +} + +} // namespace + +TEST(OpenAiResponsesTest, ConvertsStatelessImagesToolDefinitionsAndResults) { + auto chat = chat_body(); + chat["stream"] = true; + chat["stream_options"] = {{"include_usage", true}}; + chat["max_tokens"] = 4096; + chat["reasoning_effort"] = "high"; + chat["prompt_cache_key"] = "session-cache"; + chat["tool_choice"] = {{"type", "function"}, {"function", {{"name", "read_file"}}}}; + chat["messages"][1]["content"] = Json::array({ + Json{{"type", "text"}, {"text", "Inspect"}}, + Json{{"type", "image_url"}, {"image_url", {{"url", "data:image/png;base64,AAAA"}, {"detail", "high"}}}}, + }); + chat["messages"].push_back({{"role", "assistant"}, {"content", nullptr}, + {"tool_calls", Json::array({chat_function(function_item())})}}); + chat["messages"].push_back({{"role", "tool"}, {"tool_call_id", "call_1"}, {"content", "source"}}); + chat["tools"] = Json::array({Json{{"type", "function"}, {"function", { + {"name", "read_file"}, {"description", "Read a file"}, + {"parameters", {{"type", "object"}, {"properties", {{"path", {{"type", "string"}}}}}}}, + }}}}); + std::string error; + const auto request = acecode::build_openai_responses_request(chat, nullptr, &error); + ASSERT_TRUE(error.empty()) << error; + EXPECT_EQ(request["store"], false); + EXPECT_EQ(request["include"], Json::array({"reasoning.encrypted_content"})); + EXPECT_EQ(request["max_output_tokens"], 4096); + EXPECT_EQ(request["reasoning"]["effort"], "high"); + EXPECT_EQ(request["prompt_cache_key"], "session-cache"); + EXPECT_EQ(request["tool_choice"], (Json{{"type", "function"}, {"name", "read_file"}})); + EXPECT_FALSE(request.contains("messages")); + EXPECT_FALSE(request.contains("stream_options")); + EXPECT_FALSE(request.contains("max_tokens")); + EXPECT_FALSE(request.contains("reasoning_effort")); + ASSERT_EQ(request["input"].size(), 4u) << request.dump(2); + EXPECT_EQ(request["input"][1]["content"][0]["type"], "input_text"); + EXPECT_EQ(request["input"][1]["content"][1]["type"], "input_image"); + EXPECT_EQ(request["input"][1]["content"][1]["detail"], "high"); + EXPECT_EQ(request["input"][2]["call_id"], "call_1"); + EXPECT_EQ(request["input"][3]["type"], "function_call_output"); + EXPECT_EQ(request["input"][3]["output"], "source"); + EXPECT_EQ(request["tools"][0]["strict"], false); + EXPECT_FALSE(request["tools"][0]["parameters"].contains("required")); +} + +TEST(OpenAiResponsesTest, ReplaysOrderedNativeGroupWithoutDuplicatingTextOrCalls) { + const auto output = Json::array({reasoning_item(), message_item("I will inspect."), function_item()}); + const auto parsed = acecode::parse_openai_responses_response(envelope(output)); + ASSERT_FALSE(parsed.provider_error.has_error()); + ChatMessage original; + original.role = "assistant"; + original.content = parsed.content; + original.content_parts = parsed.content_parts; + original.tool_calls = Json::array({chat_function(function_item())}); + std::vector originals{original}; + auto chat = chat_body(); + chat["messages"].push_back({{"role", "assistant"}, {"content", original.content}, + {"tool_calls", original.tool_calls}}); + const auto request = acecode::build_openai_responses_request(chat, &originals); + ASSERT_EQ(request["input"].size(), 5u) << request.dump(2); + for (std::size_t i = 0; i < output.size(); ++i) EXPECT_EQ(request["input"][i + 2], output[i]); + EXPECT_EQ(request["input"][3]["phase"], "commentary"); + EXPECT_EQ(request.dump().find("openai_responses_item"), std::string::npos); +} + +TEST(OpenAiResponsesTest, StaleNativeCallsCannotUndoCanonicalHistoryRepair) { + const auto parsed = acecode::parse_openai_responses_response(envelope( + Json::array({reasoning_item(), message_item("answer"), function_item()}))); + ChatMessage original; + original.role = "assistant"; + original.content = "answer"; + original.content_parts = parsed.content_parts; + original.tool_calls = Json::array({chat_function(function_item())}); + std::vector originals{original}; + auto chat = chat_body(); + chat["messages"].push_back({{"role", "assistant"}, {"content", "answer"}}); + auto request = acecode::build_openai_responses_request(chat, &originals); + ASSERT_EQ(request["input"].size(), 3u); + EXPECT_EQ(request["input"][2]["content"], "answer"); + EXPECT_EQ(request.dump().find("complete-cipher"), std::string::npos); + EXPECT_EQ(request.dump().find("function_call"), std::string::npos); + + chat["messages"][2]["content"] = "edited answer"; + chat["messages"][2]["tool_calls"] = original.tool_calls; + request = acecode::build_openai_responses_request(chat, &originals); + ASSERT_EQ(request["input"].size(), 4u); + EXPECT_EQ(request["input"][2]["content"], "edited answer"); + EXPECT_EQ(request["input"][3]["type"], "function_call"); + EXPECT_EQ(request.dump().find("complete-cipher"), std::string::npos); +} + +TEST(OpenAiResponsesTest, MalformedNativeGroupFallsBackAtomicallyAndDroppedRowsDoNotShiftReplay) { + ChatMessage original; + original.role = "assistant"; + original.content = "answer"; + original.content_parts = Json::array({ + Json{{"type", "openai_responses_item"}, {"item", reasoning_item()}}, + Json{{"type", "openai_responses_item"}, {"item", "broken"}}, + }); + std::vector originals{original}; + auto chat = chat_body(); + chat["messages"].push_back({{"role", "assistant"}, {"content", "answer"}}); + auto request = acecode::build_openai_responses_request(chat, &originals); + ASSERT_EQ(request["input"].size(), 3u); + EXPECT_EQ(request.dump().find("cipher"), std::string::npos); + original.content_parts = acecode::parse_openai_responses_response(envelope( + Json::array({reasoning_item(), message_item("answer")}))).content_parts; + originals = {original, original}; + request = acecode::build_openai_responses_request(chat, &originals); + EXPECT_EQ(request.dump().find("cipher"), std::string::npos); +} + +TEST(OpenAiResponsesTest, RejectsInvalidRequestsWithoutRequiringErrorPointer) { + auto chat = chat_body(); + chat["messages"][1]["content"] = Json::array({Json{{"type", "image_url"}, {"image_url", Json::object()}}}); + EXPECT_TRUE(acecode::build_openai_responses_request(chat).is_null()); + std::string error; + EXPECT_TRUE(acecode::build_openai_responses_request(chat, nullptr, &error).is_null()); + EXPECT_FALSE(error.empty()); + chat = chat_body(); + chat["messages"].push_back({{"role", "tool"}, {"content", "missing ID"}}); + EXPECT_TRUE(acecode::build_openai_responses_request(chat).is_null()); + chat = chat_body(); + chat["tools"] = Json::array({Json{{"type", "web_search"}}}); + EXPECT_TRUE(acecode::build_openai_responses_request(chat).is_null()); +} + +TEST(OpenAiResponsesTest, ParsesTextRefusalReasoningParallelCallsAndUsage) { + auto message = message_item("Answer. "); + message["content"].push_back({{"type", "refusal"}, {"refusal", "Cannot do that."}}); + auto wire = envelope(Json::array({reasoning_item(), message, function_item(), function_item("call_2", "{}")})); + wire["usage"] = {{"input_tokens", 100}, {"output_tokens", 25}, {"total_tokens", 125}, + {"input_tokens_details", {{"cached_tokens", 40}}}, + {"output_tokens_details", {{"reasoning_tokens", 10}}}}; + const auto result = acecode::parse_openai_responses_response(wire); + EXPECT_EQ(result.content, "Answer. Cannot do that."); + EXPECT_EQ(result.reasoning_content, "plan"); + EXPECT_EQ(result.finish_reason, "tool_calls"); + ASSERT_EQ(result.tool_calls.size(), 2u); + EXPECT_EQ(result.tool_calls[1].id, "call_2"); + ASSERT_EQ(result.content_parts.size(), 4u); + EXPECT_EQ(result.content_parts[0]["item"]["encrypted_content"], "complete-cipher"); + EXPECT_EQ(result.content_parts[1]["item"]["phase"], "commentary"); + EXPECT_TRUE(result.usage.has_data); + EXPECT_EQ(result.usage.prompt_tokens, 100); + EXPECT_EQ(result.usage.cache_read_tokens, 40); + EXPECT_EQ(result.usage.reasoning_tokens, 10); +} + +TEST(OpenAiResponsesTest, IncompleteResponsesNeverExposeExecutableOrReplayableCalls) { + for (const auto& reason : {"max_output_tokens", "content_filter"}) { + auto wire = envelope(Json::array({message_item("partial"), + Json{{"type", "function_call"}, {"arguments", "{"}}}), "incomplete"); + wire["incomplete_details"] = {{"reason", reason}}; + wire["usage"] = {{"input_tokens", 0}, {"output_tokens", 3}, {"total_tokens", 3}}; + auto result = acecode::parse_openai_responses_response(wire); + EXPECT_EQ(result.finish_reason, std::string(reason) == "content_filter" ? "content_filter" : "length"); + EXPECT_TRUE(result.tool_calls.empty()); + ASSERT_EQ(result.content_parts.size(), 1u); + EXPECT_EQ(result.content, "partial"); + EXPECT_EQ(result.usage.completion_tokens, 3); + OpenAiResponsesStreamParser parser; + const auto events = parser.consume({{"type", "response.incomplete"}, {"response", wire}}); + EXPECT_EQ(first_event(events, StreamEventType::ToolCall), nullptr); + EXPECT_NE(first_event(events, StreamEventType::Done), nullptr); + } +} + +TEST(OpenAiResponsesTest, ValidatesEnvelopesAndNeverSynthesizesMissingCallIds) { + for (const auto& wire : { + Json::object(), envelope(Json::object()), + envelope(Json::array({Json{{"type", "message"}, {"content", 42}}})), + envelope(Json::array({Json{{"type", "function_call"}, {"name", "shell"}, {"arguments", "{}"}}})), + envelope(Json::array({function_item(), function_item()})), + envelope(Json::array({function_item("call_1", "{")})), + }) { + const auto result = acecode::parse_openai_responses_response(wire); + EXPECT_EQ(result.finish_reason, "error") << wire.dump(); + EXPECT_EQ(result.provider_error.kind, acecode::ProviderErrorKind::MalformedJson); + EXPECT_TRUE(result.tool_calls.empty()); + } +} + +TEST(OpenAiResponsesTest, StructuredErrorsPreserveQuotaClassificationAndDiagnosticBody) { + for (const auto& code : {"rate_limit_exceeded", "server_error", "insufficient_quota", "invalid_prompt"}) { + const Json wire{{"status", "failed"}, {"error", {{"code", code}, {"message", "upstream failed"}}}}; + const auto result = acecode::parse_openai_responses_response(wire); + EXPECT_EQ(result.finish_reason, "error"); + EXPECT_EQ(result.provider_error.provider, "openai"); + EXPECT_EQ(result.provider_error.raw_body, wire.dump()); + EXPECT_EQ(result.provider_error.display_message, "upstream failed"); + EXPECT_EQ(result.provider_error.retryable, + std::string(code) == "server_error" || std::string(code) == "rate_limit_exceeded"); + } +} + +TEST(OpenAiResponsesTest, StreamsToolsOnlyAfterValidatedTerminalAndNeverDuplicatesThem) { + OpenAiResponsesStreamParser parser; + auto added = function_item(); + added["arguments"] = ""; + added["status"] = "in_progress"; + EXPECT_TRUE(parser.consume({{"type", "response.output_item.added"}, {"output_index", 0}, {"item", added}}).empty()); + auto events = parser.consume({{"type", "response.function_call_arguments.delta"}, {"output_index", 0}, + {"item_id", "fc_call_1"}, {"delta", R"({"path":)"}}); + const auto* progress = first_event(events, StreamEventType::ToolCallDelta); + ASSERT_NE(progress, nullptr); + EXPECT_TRUE(progress->tool_call.function_arguments.empty()); + EXPECT_EQ(progress->tool_call_argument_bytes, 8u); + EXPECT_EQ(progress->tool_call.id, "call_1"); + EXPECT_TRUE(parser.consume({{"type", "response.function_call_arguments.done"}, {"output_index", 0}, + {"item_id", "fc_call_1"}, {"arguments", function_item()["arguments"]}}).empty()); + events = parser.consume({{"type", "response.output_item.done"}, {"output_index", 0}, {"item", function_item()}}); + EXPECT_EQ(first_event(events, StreamEventType::ToolCall), nullptr); + EXPECT_TRUE(parser.accumulated().tool_calls.empty()); + events = parser.consume(terminal(Json::array({function_item()}))); + EXPECT_EQ(count_events(events, StreamEventType::ToolCall), 1u); + EXPECT_EQ(count_events(events, StreamEventType::Done), 1u); + ASSERT_EQ(parser.accumulated().tool_calls.size(), 1u); + EXPECT_EQ(parser.accumulated().tool_calls[0].function_arguments, function_item()["arguments"]); + EXPECT_TRUE(parser.consume(terminal(Json::array({function_item()}))).empty()); + EXPECT_TRUE(parser.finish().empty()); +} + +TEST(OpenAiResponsesTest, ReconcilesFinalOnlyTextAndReasoningWithoutDuplicatingStreamedPrefix) { + OpenAiResponsesStreamParser parser; + auto events = parser.consume({{"type", "response.output_text.delta"}, {"output_index", 1}, {"delta", "hel"}}); + ASSERT_NE(first_event(events, StreamEventType::Delta), nullptr); + events = parser.consume({{"type", "response.reasoning_summary_text.delta"}, {"output_index", 0}, {"delta", "pl"}}); + ASSERT_NE(first_event(events, StreamEventType::ReasoningDelta), nullptr); + events = parser.consume(terminal(Json::array({reasoning_item(), message_item("hello")}))); + ASSERT_NE(first_event(events, StreamEventType::Delta), nullptr); + EXPECT_EQ(first_event(events, StreamEventType::Delta)->content, "lo"); + ASSERT_NE(first_event(events, StreamEventType::ReasoningDelta), nullptr); + EXPECT_EQ(first_event(events, StreamEventType::ReasoningDelta)->content, "an"); + EXPECT_EQ(parser.accumulated().content, "hello"); + EXPECT_EQ(parser.accumulated().reasoning_content, "plan"); + + OpenAiResponsesStreamParser final_only; + events = final_only.consume(terminal(Json::array({message_item("final only")}))); + ASSERT_NE(first_event(events, StreamEventType::Delta), nullptr); + EXPECT_EQ(first_event(events, StreamEventType::Delta)->content, "final only"); +} + +TEST(OpenAiResponsesTest, RetainsCompletedEncryptedItemAndPhaseInsteadOfPreliminaryCipher) { + OpenAiResponsesStreamParser parser; + auto reasoning = reasoning_item(); + auto preliminary = reasoning; + preliminary["encrypted_content"] = "partial-cipher"; + parser.consume({{"type", "response.output_item.added"}, {"output_index", 0}, {"item", preliminary}}); + parser.consume({{"type", "response.output_item.done"}, {"output_index", 0}, {"item", reasoning}}); + const auto message = message_item("answer"); + parser.consume({{"type", "response.output_item.done"}, {"output_index", 1}, {"item", message}}); + auto final_reasoning = reasoning; + final_reasoning.erase("encrypted_content"); + auto final_message = message; + final_message.erase("phase"); + const auto events = parser.consume(terminal(Json::array({final_reasoning, final_message}))); + const auto* done = first_event(events, StreamEventType::Done); + ASSERT_NE(done, nullptr); + ASSERT_EQ(done->content_parts.size(), 2u); + EXPECT_EQ(done->content_parts[0]["item"]["encrypted_content"], "complete-cipher"); + EXPECT_EQ(done->content_parts[1]["item"]["phase"], "commentary"); + EXPECT_EQ(done->content_parts.dump().find("partial-cipher"), std::string::npos); +} + +TEST(OpenAiResponsesTest, TruncationAndLateFailureCannotReleaseCompletedToolItems) { + for (bool explicit_failure : {false, true}) { + OpenAiResponsesStreamParser parser; + parser.consume({{"type", "response.output_item.done"}, {"output_index", 0}, {"item", function_item()}}); + const auto events = explicit_failure + ? parser.consume({{"type", "response.failed"}, {"response", { + {"status", "failed"}, {"error", {{"code", "insufficient_quota"}, {"message", "quota"}}}}}}) + : parser.finish(); + const auto* error = first_event(events, StreamEventType::Error); + ASSERT_NE(error, nullptr); + EXPECT_EQ(first_event(events, StreamEventType::ToolCall), nullptr); + EXPECT_EQ(first_event(events, StreamEventType::Done), nullptr); + EXPECT_EQ(error->provider_error.retryable, !explicit_failure); + EXPECT_TRUE(parser.accumulated().tool_calls.empty()); + EXPECT_TRUE(parser.accumulated().content_parts.empty()); + } +} + +TEST(OpenAiResponsesTest, IncompleteOutputItemCanPrecedeIncompleteTerminal) { + OpenAiResponsesStreamParser parser; + const Json partial{{"id", "fc_1"}, {"type", "function_call"}, + {"status", "incomplete"}, {"arguments", "{"}}; + EXPECT_TRUE(parser.consume({{"type", "response.output_item.done"}, + {"output_index", 0}, {"item", partial}}).empty()); + const auto events = parser.consume(terminal(Json::array({partial}), "incomplete")); + const auto* done = first_event(events, StreamEventType::Done); + ASSERT_NE(done, nullptr); + EXPECT_EQ(done->finish_reason, "length"); + EXPECT_TRUE(done->content_parts.empty()); + EXPECT_EQ(first_event(events, StreamEventType::ToolCall), nullptr); +} + +TEST(OpenAiResponsesTest, KnownMalformedEventsFailWhileUnknownEventsAreIgnored) { + const std::vector malformed{ + Json::array(), Json{{"type", 7}}, + Json{{"type", "response.output_text.delta"}, {"output_index", 0}, {"delta", 42}}, + Json{{"type", "response.output_text.delta"}, {"output_index", -1}, {"delta", "bad"}}, + Json{{"type", "response.output_text.delta"}, {"output_index", 0}, {"item_id", 42}, {"delta", "bad"}}, + Json{{"type", "response.output_item.done"}, {"output_index", 0}}, + Json{{"type", "response.output_item.added"}, {"output_index", 0}, {"item", {{"type", "function_call"}, {"name", 42}}}}, + Json{{"type", "response.function_call_arguments.done"}, {"output_index", 0}, {"arguments", Json::object()}}, + Json{{"type", "response.content_part.done"}, {"output_index", 0}, {"part", nullptr}}, + Json{{"type", "response.content_part.done"}, {"output_index", 0}, {"part", {{"type", "output_text"}, {"text", 42}}}}, + Json{{"type", "response.completed"}}, + Json{{"type", "response.completed"}, {"response", {{"status", "completed"}}}}, + }; + for (const auto& event : malformed) { + OpenAiResponsesStreamParser parser; + const auto events = parser.consume(event); + const auto* error = first_event(events, StreamEventType::Error); + ASSERT_NE(error, nullptr) << event.dump(); + EXPECT_EQ(error->provider_error.kind, acecode::ProviderErrorKind::MalformedSse); + EXPECT_FALSE(error->provider_error.retryable); + } + OpenAiResponsesStreamParser parser; + EXPECT_TRUE(parser.consume({{"type", "response.future_event"}, {"future", Json::array()}}).empty()); + EXPECT_FALSE(parser.terminal()); +} + +TEST(OpenAiResponsesTest, RejectsReusedOutputIdsAndContradictoryTerminalPayloads) { + OpenAiResponsesStreamParser reused; + reused.consume({{"type", "response.output_item.added"}, {"output_index", 0}, {"item", function_item()}}); + auto events = reused.consume({{"type", "response.function_call_arguments.delta"}, + {"output_index", 1}, {"item_id", "fc_call_1"}, {"delta", "{"}}); + EXPECT_NE(first_event(events, StreamEventType::Error), nullptr); + + OpenAiResponsesStreamParser changed; + changed.consume({{"type", "response.output_text.delta"}, {"output_index", 0}, {"delta", "expected"}}); + events = changed.consume(terminal(Json::array({message_item("different"), function_item()}))); + EXPECT_NE(first_event(events, StreamEventType::Error), nullptr); + EXPECT_EQ(first_event(events, StreamEventType::ToolCall), nullptr); + + OpenAiResponsesStreamParser missing; + missing.consume({{"type", "response.output_item.done"}, {"output_index", 0}, {"item", function_item()}}); + events = missing.consume(terminal(Json::array())); + EXPECT_NE(first_event(events, StreamEventType::Error), nullptr); +} + +TEST(OpenAiResponsesTest, UsageCountsSaturateWithoutOverflowAndZeroUsageIsRetained) { + auto wire = envelope(Json::array()); + wire["usage"] = {{"input_tokens", (std::numeric_limits::max)()}, + {"output_tokens", (std::numeric_limits::max)()}}; + auto result = acecode::parse_openai_responses_response(wire); + EXPECT_EQ(result.usage.prompt_tokens, (std::numeric_limits::max)()); + EXPECT_EQ(result.usage.total_tokens, (std::numeric_limits::max)()); + wire["usage"] = {{"input_tokens", 0}, {"output_tokens", 0}, {"total_tokens", 0}}; + result = acecode::parse_openai_responses_response(wire); + EXPECT_TRUE(result.usage.has_data); + EXPECT_EQ(result.usage.total_tokens, 0); +} diff --git a/tests/provider/provider_factory_test.cpp b/tests/provider/provider_factory_test.cpp index f3b4e9216..a68580dba 100644 --- a/tests/provider/provider_factory_test.cpp +++ b/tests/provider/provider_factory_test.cpp @@ -2,6 +2,7 @@ #include "config/config.hpp" #include "provider/provider_factory.hpp" +#include "provider/openai_responses_provider.hpp" #include @@ -223,6 +224,7 @@ TEST(ProviderFactory, FingerprintCoversEveryEffectiveConstructionField) { [](ModelProfile& p) { p.stream_timeout_ms = 42000; }, [](ModelProfile& p) { p.request_headers["Authorization"] = "Bearer new"; }, [](ModelProfile& p) { p.endpoint_mode = "full_url"; }, + [](ModelProfile& p) { p.api_protocol = "responses"; }, [](ModelProfile& p) { p.max_output_tokens = 8193; }, [](ModelProfile& p) { p.reasoning->supported = false; }, [](ModelProfile& p) { p.reasoning->mandatory = true; }, @@ -278,6 +280,42 @@ TEST(ProviderFactory, FingerprintCoversDerivedReasoningWireProtocol) { EXPECT_NE(fingerprint_for(profile), ordinary); } +TEST(ProviderFactory, ResponsesProtocolSelectsAdapterAndLegacyRemainsChat) { + auto profile = openai_profile("https://api.example/v1/"); + const auto legacy_fingerprint = fingerprint_for(profile); + auto legacy = create_provider_from_entry(profile); + ASSERT_TRUE(legacy); + EXPECT_FALSE(std::dynamic_pointer_cast(legacy)); + + profile.api_protocol = "chat_completions"; + EXPECT_EQ(fingerprint_for(profile), legacy_fingerprint); + profile.api_protocol = "responses"; + EXPECT_NE(fingerprint_for(profile), legacy_fingerprint); + auto provider = create_provider_from_entry(profile); + auto responses = std::dynamic_pointer_cast(provider); + ASSERT_TRUE(responses); + EXPECT_EQ(responses->name(), "openai"); + EXPECT_EQ(responses->request_url(), "https://api.example/v1/responses"); + + profile.endpoint_mode = "full_url"; + profile.base_url = "https://api.example/custom/responses?version=1"; + responses = std::dynamic_pointer_cast( + create_provider_from_entry(profile)); + ASSERT_TRUE(responses); + EXPECT_EQ(responses->request_url(), profile.base_url); +} + +TEST(ProviderFactory, RejectsInvalidOrNonOpenAiProtocolEvenWithoutConfigValidation) { + auto profile = openai_profile("https://api.example/v1"); + profile.api_protocol = "unknown"; + EXPECT_FALSE(acecode::prepare_provider_construction(profile).has_value()); + profile.api_protocol = "responses"; + for (const auto* provider : {"anthropic", "copilot", "grok", "codex"}) { + profile.provider = provider; + EXPECT_FALSE(acecode::prepare_provider_construction(profile).has_value()); + } +} + // 触发场景:仅重命名或修改 context_window;期望 Provider 指纹保持相同。 // 这些是状态/元数据字段,旧实现若混入比较会做无意义的连接重建。 TEST(ProviderFactory, RenameAndContextOnlyChangesAreFingerprintEquivalent) { diff --git a/tests/provider/retry_policy_test.cpp b/tests/provider/retry_policy_test.cpp index 0fbe54141..2664b1ccc 100644 --- a/tests/provider/retry_policy_test.cpp +++ b/tests/provider/retry_policy_test.cpp @@ -25,6 +25,40 @@ TEST(ProviderRetryPolicy, ServerDelayOverridesLocalDelayButUsesSameCap) { EXPECT_EQ(acecode::provider_retry_delay_ms(1, 3600000), 1200000); } +// 触发场景:429 限流(或按 PA 文案判定的限流)且服务端没给 Retry-After。 +// 期望行为:本地翻倍退避封顶 1 分钟,而不是 20 分钟。 +// 回归背景(2026-10-08 反馈):内网网关限速「100 次 / 10 分钟」,旧退避第 10 次 +// 要等 512 秒、第 12 次起每次 20 分钟,窗口早已复位之后还在白等;用户看到的是 +// 界面长时间停在「等待重试」。非限流故障(5xx / 超时)的上限不变。 +TEST(ProviderRetryPolicy, RateLimitBackoffCapsAtOneMinute) { + EXPECT_EQ(acecode::provider_retry_max_delay_ms(429, ""), 60000); + EXPECT_EQ(acecode::provider_retry_max_delay_ms(503, ""), 1200000); + EXPECT_EQ(acecode::provider_retry_max_delay_ms( + 400, "{\"message\":\"\xE6\x82\xA8\xE7\x9A\x84\xE8\xAF\xB7\xE6\xB1\x82" + "\xE9\xA2\x91\xE7\x8E\x87\xE5\xB7\xB2\xE8\xBE\xBE\xE5\x88\xB0" + "\xE9\x99\x90\xE5\x88\xB6\"}"), // 您的请求频率已达到限制 + 60000); + // 硬配额不是限流:它根本不会重试,上限按普通值给,不影响判定。 + EXPECT_FALSE(acecode::provider_error_is_rate_limited( + 429, R"({"error":{"code":"insufficient_quota"}})")); + + EXPECT_EQ(acecode::provider_retry_delay_ms(1, std::nullopt, 60000), 1000); + EXPECT_EQ(acecode::provider_retry_delay_ms(6, std::nullopt, 60000), 32000); + EXPECT_EQ(acecode::provider_retry_delay_ms(7, std::nullopt, 60000), 60000); + EXPECT_EQ(acecode::provider_retry_delay_ms(1000000, std::nullopt, 60000), 60000); + // 默认上限不变:老调用方的行为逐字节一致。 + EXPECT_EQ(acecode::provider_retry_delay_ms(12, std::nullopt), 1200000); +} + +// 触发场景:限流响应带了 Retry-After。 +// 期望行为:服务端给的时长优先,不被 1 分钟的本地上限截短;仍以 20 分钟封顶。 +// 限流网关说「10 分钟后再来」时按 1 分钟去探只会多吃几次拒绝。 +TEST(ProviderRetryPolicy, ServerRetryAfterIsHonoredBeyondRateLimitCap) { + EXPECT_EQ(acecode::provider_retry_delay_ms(1, 600000, 60000), 600000); + EXPECT_EQ(acecode::provider_retry_delay_ms(1, 3600000, 60000), 1200000); + EXPECT_EQ(acecode::provider_retry_delay_ms(3, 0, 60000), 0); +} + TEST(ProviderRetryPolicy, ParsesDeltaSecondsAndHttpDate) { EXPECT_EQ(acecode::parse_retry_after_ms("2.5", 0), 2500); EXPECT_EQ( diff --git a/tests/provider/session_model_binding_test.cpp b/tests/provider/session_model_binding_test.cpp index 68747e9d7..aecd20117 100644 --- a/tests/provider/session_model_binding_test.cpp +++ b/tests/provider/session_model_binding_test.cpp @@ -2,6 +2,7 @@ #include "provider/session_model_binding.hpp" #include "provider/openai_provider.hpp" +#include "provider/openai_responses_provider.hpp" #include #include @@ -123,6 +124,37 @@ TEST(SessionModelBinding, ClonedBindingCanIndependentlyContinueAgain) { EXPECT_EQ(second->provider->model(), source.provider_snapshot()->model()); } +TEST(SessionModelBinding, ProtocolEditRebuildsFutureSnapshotAndClonesResponses) { + acecode::AppConfig cfg; + auto profile = profile_named(); + cfg.saved_models = {profile}; + acecode::SessionModelBinding binding; + ASSERT_TRUE(install_initial(binding, target_for(profile, cfg, 1)).ok); + const auto in_flight = binding.provider_snapshot(); + + profile.api_protocol = "responses"; + cfg.saved_models = {profile}; + const auto updated = target_for(profile, cfg, 2); + const auto result = binding.ensure_current( + false, [] { return acecode::SavedModelsRevision{2}; }, + [updated](const std::string&) { return updated; }); + ASSERT_TRUE(result.ok); + EXPECT_EQ(result.outcome, acecode::SessionModelReloadOutcome::Reloaded); + const auto current = std::dynamic_pointer_cast( + binding.provider_snapshot()); + ASSERT_TRUE(current); + EXPECT_NE(current, in_flight); + const auto previous = std::dynamic_pointer_cast(in_flight); + ASSERT_TRUE(previous); + EXPECT_EQ(previous->request_url(), "https://gateway.example/v1/chat/completions"); + EXPECT_EQ(current->request_url(), "https://gateway.example/v1/responses"); + + const auto cloned = binding.clone_runtime_snapshot(profile.name); + ASSERT_TRUE(cloned); + EXPECT_NE(cloned->provider, current); + EXPECT_TRUE(std::dynamic_pointer_cast(cloned->provider)); +} + TEST(SessionModelBinding, CloneRejectsChangedSelectionAndUncloneableRuntimeProvider) { acecode::AppConfig cfg; auto profile = profile_named("original"); diff --git a/tests/web/models_handler_test.cpp b/tests/web/models_handler_test.cpp index 6486e2881..db82aecc7 100644 --- a/tests/web/models_handler_test.cpp +++ b/tests/web/models_handler_test.cpp @@ -811,3 +811,33 @@ TEST(ModelsHandler, ModelStateSerializesReasoningAndNullableSessionOverride) { state.reasoning.reset(); EXPECT_TRUE(model_state_to_json(state)["reasoning"].is_null()); } + +// API 往返保留协议;省略与 null 区分保留和清除,错误类型不得静默忽略。 +TEST(ModelsHandler, ApiProtocolRoundTripAndDraftPresence) { + auto cfg = make_cfg_with_two(); + auto& profile = cfg.saved_models.back(); + profile.api_protocol = "responses"; + const auto wire = profile_to_json(profile); + EXPECT_EQ(wire["api_protocol"], "responses"); + EXPECT_EQ(list_models(cfg).back()["api_protocol"], "responses"); + std::string error; + auto draft = parse_model_draft(wire, error); + ASSERT_TRUE(draft.has_value()) << error; + EXPECT_TRUE(draft->api_protocol_supplied); + EXPECT_EQ(draft->api_protocol, "responses"); + auto missing = wire; + missing.erase("api_protocol"); + draft = parse_model_draft(missing, error); + ASSERT_TRUE(draft.has_value()) << error; + EXPECT_FALSE(draft->api_protocol_supplied); + auto clear = wire; + clear["api_protocol"] = nullptr; + draft = parse_model_draft(clear, error); + ASSERT_TRUE(draft.has_value()) << error; + EXPECT_TRUE(draft->api_protocol_supplied); + EXPECT_FALSE(draft->api_protocol.has_value()); + auto invalid = wire; + invalid["api_protocol"] = 42; + EXPECT_FALSE(parse_model_draft(invalid, error)); + EXPECT_NE(error.find("api_protocol"), std::string::npos); +} diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index d150e5926..b06ebfa83 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,9 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + "API 协议": "API protocol", + "按服务商支持的接口选择;需要 Responses 的模型请在此切换。": "Choose the interface supported by your provider. Select Responses for models that require it.", + "API 协议无效,请选择 Chat Completions 或 Responses": "Invalid API protocol. Select Chat Completions or Responses.", '打开设置': 'Open settings', '在内置浏览器中打开': 'Open in built-in browser', '浏览器页面已关闭': 'The browser page has been closed', diff --git a/web/src/components/model-settings/ModelProfileDialog.jsx b/web/src/components/model-settings/ModelProfileDialog.jsx index f18afa9c7..b0fb2bd68 100644 --- a/web/src/components/model-settings/ModelProfileDialog.jsx +++ b/web/src/components/model-settings/ModelProfileDialog.jsx @@ -42,6 +42,27 @@ function fieldLabel(id, label, optional = false) { ); } +function ModelApiProtocolField({ draft, onPatchDraft }) { + return ( +
+ {fieldLabel('model-api-protocol', 'API 协议')} + +

+ 按服务商支持的接口选择;需要 Responses 的模型请在此切换。 +

+
+ ); +} + function ModelApiKeyInput({ draft, apiKeyVisible, onPatchDraft, onToggleApiKey, testStatus, onTest, submitting }) { const testing = testStatus === 'testing'; @@ -159,6 +180,9 @@ function CustomCompatibilityApiFields({ onPatchDraft={onPatchDraft} /> + {policy?.show_api_protocol && ( + + )} {policy?.show_base_url && (
{fieldLabel('model-base-url', draft.endpoint_mode === 'full_url' ? '完整端点 URL' : 'Base URL')} @@ -459,6 +483,9 @@ export function ModelProfileDialog({ {!policy?.managed && !customCompatibilityApi && (
+ {policy?.show_api_protocol && ( + + )} {policy?.show_base_url && (
{fieldLabel('model-base-url', draft.endpoint_mode === 'full_url' ? '完整端点 URL' : 'Base URL')} diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 5c14a447d..d46ef1da6 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -1551,6 +1551,7 @@ export const sourceCatalogs = { "s_916a15661d20131c": "已暂停的目标", "s_916afcd85322d732": "更换", "s_917b1c1f18d0276b": "保存中...", + "s_91e22b0baedeffd2": "API 协议无效,请选择 Chat Completions 或 Responses", "s_92172b32227b0aae": "删除后无法恢复;记忆摘要之后也不会再自动生成这条记忆。", "s_926a50b98ece2667": "撤销", "s_92845d3b531d9fec": "每周", @@ -1789,6 +1790,7 @@ export const sourceCatalogs = { "s_aa5e627d1fad8738": "正在导入主题…", "s_aa5f39fc482f38db": "高级模式", "s_aa6d8879058e32fb": "本会话暂无文件变更", + "s_aa946ad4ec4c58f9": "API 协议", "s_aa9e366f68d3d097": "低", "s_aae110840475eea7": "管理 ACECode 可调用的技能模块。启用后 Agent 在任务中可自动使用。", "s_aae17129468e10f4": "拦截 {{p0}} 次 · {{p1}}", @@ -2670,6 +2672,7 @@ export const sourceCatalogs = { "s_fd6d4b5099959168": "插话", "s_fd7b67c923cd4b3e": "每日", "s_fdd200f5dbdb26fc": "选择 Agent 显示多少技术细节", + "s_fddeb12861d6d200": "按服务商支持的接口选择;需要 Responses 的模型请在此切换。", "s_fdfc8be6c3e9fa53": "问一个不打断当前任务的问题…", "s_fe0ba53d223b301c": "更新服务返回状态 {{p0}}", "s_fe6d0b07c1c680f6": "主理人:", @@ -4242,6 +4245,7 @@ export const sourceCatalogs = { "s_916a15661d20131c": "Paused goal", "s_916afcd85322d732": "Change", "s_917b1c1f18d0276b": "Saving...", + "s_91e22b0baedeffd2": "Invalid API protocol. Select Chat Completions or Responses.", "s_92172b32227b0aae": "This cannot be undone, and memory summarization will not recreate this memory.", "s_926a50b98ece2667": "Cancel", "s_92845d3b531d9fec": "Weekly", @@ -4480,6 +4484,7 @@ export const sourceCatalogs = { "s_aa5e627d1fad8738": "Importing theme…", "s_aa5f39fc482f38db": "Advanced Mode", "s_aa6d8879058e32fb": "There are no file changes in this session", + "s_aa946ad4ec4c58f9": "API protocol", "s_aa9e366f68d3d097": "Low", "s_aae110840475eea7": "Manage ACECode callable skill modules. After enabling, Agent can be used automatically in tasks.", "s_aae17129468e10f4": "Blocked {{p0}} times · {{p1}}", @@ -5361,6 +5366,7 @@ export const sourceCatalogs = { "s_fd6d4b5099959168": "Interject", "s_fd7b67c923cd4b3e": "Daily", "s_fdd200f5dbdb26fc": "Choose how much technical detail Agent shows", + "s_fddeb12861d6d200": "Choose the interface supported by your provider. Select Responses for models that require it.", "s_fdfc8be6c3e9fa53": "Ask a question without interrupting the current task…", "s_fe0ba53d223b301c": "Update service returns status {{p0}}", "s_fe6d0b07c1c680f6": "Lead:", diff --git a/web/src/lib/errors.js b/web/src/lib/errors.js index 54d4e8733..dada2605a 100644 --- a/web/src/lib/errors.js +++ b/web/src/lib/errors.js @@ -37,6 +37,7 @@ const TABLE = { MISSING_MODEL: '请填写 model', MISSING_BASE_URL: '该 provider 必须填写 base_url', INVALID_API_KEY: '该 provider 的 API key 不能为空', + INVALID_API_PROTOCOL: 'API 协议无效,请选择 Chat Completions 或 Responses', INVALID_CONTEXT_WINDOW: '上下文窗口必须是大于 0 的数字', INVALID_CAPABILITY: '模型能力标签无效', INVALID_REQUEST_HEADER: '自定义请求头 JSON 无效', diff --git a/web/src/lib/modelConnectionTest.test.js b/web/src/lib/modelConnectionTest.test.js index f3a786055..68c73c677 100644 --- a/web/src/lib/modelConnectionTest.test.js +++ b/web/src/lib/modelConnectionTest.test.js @@ -114,3 +114,13 @@ await run('model failure copy does not echo upstream error bodies or draft secre assert.ok(!message.includes('secret')); assert.match(message, /模型检测失败/); }); + +await run('model test carries the selected Responses protocol to every request', async () => { + const calls = []; + await testModelDraft({ testModel: async (payload) => { + calls.push(payload); + return { ok: true }; + } }, { ...draft, api_protocol: 'responses' }, provider); + assert.equal(calls.length, 2); + assert.ok(calls.every((payload) => payload.api_protocol === 'responses')); +}); diff --git a/web/src/lib/modelSettings.js b/web/src/lib/modelSettings.js index dffc38cc7..96337ca6a 100644 --- a/web/src/lib/modelSettings.js +++ b/web/src/lib/modelSettings.js @@ -20,6 +20,7 @@ import { providerDisplayName } from './providerCatalogGroups.js'; export const MODEL_CATALOG_QUERY_LIMIT = 50; export const MODEL_ENDPOINT_MODES = ['base_url', 'full_url']; +export const MODEL_API_PROTOCOLS = ['chat_completions', 'responses']; import { MODEL_REASONING_EFFORTS } from './modelReasoning.js'; export { MODEL_REASONING_EFFORTS } from './modelReasoning.js'; @@ -385,6 +386,10 @@ export function normalizeSavedModelProfile(raw, index = 0) { throw contractError(`saved_models[${index}].provider is unsupported`); } const apiKey = optionalString(value.api_key, `saved_models[${index}].api_key`); + const apiProtocol = optionalString(value.api_protocol, `saved_models[${index}].api_protocol`); + if (apiProtocol && (provider !== 'openai' || !MODEL_API_PROTOCOLS.includes(apiProtocol))) { + throw contractError(`saved_models[${index}].api_protocol is unsupported`); + } return { ...value, name: requireString(value.name, `saved_models[${index}].name`), @@ -416,6 +421,7 @@ export function normalizeSavedModelProfile(raw, index = 0) { : MODEL_ENDPOINT_MODES.includes(value.endpoint_mode) ? value.endpoint_mode : (() => { throw contractError(`saved_models[${index}].endpoint_mode is unsupported`); })(), + api_protocol: apiProtocol, reasoning: normalizeReasoning(value.reasoning, `saved_models[${index}].reasoning`), request_headers: value.request_headers === undefined || value.request_headers === null ? undefined @@ -771,6 +777,7 @@ export function emptyModelProfileDraft() { model: '', base_url: OPENAI_DEFAULT_BASE_URL, endpoint_mode: 'base_url', + api_protocol: '', api_key: '', has_api_key: false, clear_api_key: false, @@ -805,6 +812,7 @@ export function modelProfileDraftFromSaved(raw) { model.provider === 'openai' ? OPENAI_DEFAULT_BASE_URL : '' ), endpoint_mode: model.endpoint_mode, + api_protocol: model.api_protocol, api_key: model.api_key, has_api_key: model.has_api_key || !!model.api_key, request_headers_json: formatRequestHeadersJson(model.request_headers), @@ -833,6 +841,7 @@ export function modelFieldPolicy(provider) { show_base_url: supportsHttpOptions && (!!value.base_url || !!value.endpoint_editable), edit_base_url: supportsHttpOptions && !!value.endpoint_editable, show_endpoint_mode: supportsHttpOptions && endpointModes.includes('full_url'), + show_api_protocol: supportsHttpOptions && value.runtime_provider === 'openai', show_request_headers: supportsHttpOptions, show_max_output: !managed, show_reasoning: supportsHttpOptions, @@ -859,6 +868,10 @@ export function applyCatalogProviderToDraft(draft, provider) { model: '', base_url: policy.show_base_url ? provider.base_url : '', endpoint_mode: provider.endpoint_modes?.[0] || 'base_url', + // An explicit provider switch must clear a previously selected Responses + // protocol even when both providers share the openai runtime kind. + api_protocol: provider.runtime_provider === 'openai' && draft?.api_protocol + ? 'chat_completions' : '', api_key: '', has_api_key: false, clear_api_key: !!draft?.has_api_key && provider.auth_mode === 'none', @@ -963,6 +976,10 @@ export function validateModelProfileDraft(draft, provider, { editing = false } = if (draft.clear_api_key && !policy.can_clear_api_key) { return { ok: false, code: 'INVALID_API_KEY' }; } + if (draft.api_protocol && (!policy.show_api_protocol + || !MODEL_API_PROTOCOLS.includes(draft.api_protocol))) { + return { ok: false, code: 'INVALID_API_PROTOCOL' }; + } if (draft.endpoint_mode === 'full_url' && !policy.show_endpoint_mode) { return { ok: false, code: 'INVALID_ENDPOINT_MODE' }; } @@ -1047,6 +1064,7 @@ export function buildModelMutationPayload(draft, provider, options = {}) { payload.models_dev_provider_id = null; } if (policy.show_base_url) payload.base_url = String(draft.base_url || '').trim(); + if (policy.show_api_protocol && draft.api_protocol) payload.api_protocol = draft.api_protocol; if (policy.show_endpoint_mode) payload.endpoint_mode = draft.endpoint_mode || 'base_url'; else if (options.editing && !policy.managed) payload.endpoint_mode = null; if (policy.show_api_key && String(draft.api_key || '').trim()) { diff --git a/web/src/lib/modelSettings.test.js b/web/src/lib/modelSettings.test.js index 8715919b6..8fee1f987 100644 --- a/web/src/lib/modelSettings.test.js +++ b/web/src/lib/modelSettings.test.js @@ -353,6 +353,7 @@ run('切换到 Copilot 清空秘密与端点并隐藏 API Key/Base URL', () => { show_base_url: false, edit_base_url: false, show_endpoint_mode: false, + show_api_protocol: false, show_request_headers: false, show_max_output: false, show_reasoning: false, @@ -513,6 +514,7 @@ run('Grok Coding Plan 使用受管路径且 payload 不含端点、密钥或运 show_base_url: false, edit_base_url: false, show_endpoint_mode: false, + show_api_protocol: false, show_request_headers: false, show_max_output: false, show_reasoning: false, @@ -1380,3 +1382,51 @@ run('ACEModel reasoning refresh preserves same-model manual capabilities without assert.deepEqual(reprobedPayloads.payloads[0].capabilities, ['vision', 'reasoning']); assert.deepEqual(reprobedPayloads.payloads[1].capabilities, ['reasoning']); }); + +run('Responses 协议贯通保存、编辑及显式切回 Chat Completions', () => { + const saved = { + name: 'responses-model', provider: 'openai', model: 'model-id', + base_url: 'https://api.example/v1', api_key: 'test-key', api_protocol: 'responses', + }; + const restored = modelProfileDraftFromSaved(saved); + assert.equal(restored.api_protocol, 'responses'); + assert.equal(modelFieldPolicy(customProvider).show_api_protocol, true); + const result = buildModelMutationPayload(restored, customProvider, { editing: true }); + assert.equal(result.ok, true); + assert.equal(result.payload.api_protocol, 'responses'); + const chat = buildModelMutationPayload({ ...restored, api_protocol: 'chat_completions' }, customProvider, { editing: true }); + assert.equal(chat.payload.api_protocol, 'chat_completions'); + const legacy = modelProfileDraftFromSaved({ ...saved, api_protocol: undefined }); + assert.equal(legacy.api_protocol, ''); + assert.equal(Object.hasOwn(buildModelMutationPayload(legacy, customProvider).payload, 'api_protocol'), false); + assert.equal(validateModelProfileDraft({ ...restored, api_protocol: 'auto' }, customProvider).code, 'INVALID_API_PROTOCOL'); + assert.throws(() => normalizeSavedModelList([{ ...saved, api_protocol: 'auto' }]), /api_protocol is unsupported/); +}); + +run('协议不泄露到非 OpenAI Provider,切换 Provider 时重置选择', () => { + for (const provider of [anthropicProvider, copilotProvider, grokProvider]) { + const switched = applyCatalogProviderToDraft({ ...emptyModelProfileDraft(), api_protocol: 'responses' }, provider); + assert.equal(switched.api_protocol, ''); + assert.equal(modelFieldPolicy(provider).show_api_protocol, false); + const draft = { ...switched, name: 'model', model: 'id', api_key: 'test-key' }; + const result = buildModelMutationPayload(draft, provider); + assert.equal(result.ok, true); + assert.equal(Object.hasOwn(result.payload, 'api_protocol'), false); + assert.equal(validateModelProfileDraft({ ...draft, api_protocol: 'responses' }, provider).code, 'INVALID_API_PROTOCOL'); + assert.throws(() => normalizeSavedModelList([{ + name: 'model', provider: provider.runtime_provider, model: 'id', api_protocol: 'responses', + }]), /api_protocol is unsupported/); + } +}); + +run('同 OpenAI runtime 切换服务商显式恢复 Chat Completions', () => { + const previous = modelProfileDraftFromSaved({ + name: 'responses-model', provider: 'openai', model: 'model-id', + base_url: 'https://api.example/v1', api_key: 'test-key', api_protocol: 'responses', + }); + const next = applyCatalogProviderToDraft(previous, openRouterProvider); + assert.equal(next.api_protocol, 'chat_completions'); + const result = buildModelMutationPayload({ ...next, model: 'new-model', api_key: 'new-key' }, openRouterProvider, { editing: true }); + assert.equal(result.ok, true); + assert.equal(result.payload.api_protocol, 'chat_completions'); +});