feat(tools): add OpenAPI toolset - #343
Conversation
| @@ -1,5 +1,15 @@ | |||
| # Changelog | |||
|
|
|||
| ## Unreleased | |||
ed672df to
c4bd41d
Compare
AI Code Review审查结论不通过 审查范围:b6fc4f3..ed672df(feat(tools): add OpenAPI toolset,18 个文件,+1896/-2),核心为新增的 发现的问题严重
问题: 触发条件: 用户未显式配置 实际影响: 工具会将请求发送到 spec 声明主机之外的任意目标(内部地址、云元数据服务如 169.254.169.254 等),形成可利用的 SSRF 向量; 修正方向: 在拼接 URL 后对最终目标做强制校验: 中等
问题: 触发条件: 用户在 实际影响: 模型在 prompt 中看不到任何参数定义(必填项、嵌套对象、枚举全部丢失),无法生成符合 schema 的调用参数,工具调用会因缺少必填参数或类型错误而持续失败,OpenAPI 工具在该模式下实际不可用,且无任何报错提示。 修正方向: 扩展 中等
问题: 触发条件: 被调用的 operation 返回 4xx/5xx,且响应体较大(如网关错误页、长堆栈或超大 JSON 错误体)。 实际影响: 完整错误体(数十 KB 甚至更大)被注入 tool error message 并进入下一轮 LLM 请求,直接膨胀模型上下文、增加 token 成本,长响应还可能挤占上下文窗口导致后续对话质量下降。 修正方向: 在 中等
问题: 第 366-372 行仅用 触发条件: 服务器返回任何 3xx(重定向、未修改、缓存命中)且 实际影响: 重定向响应体(如 HTML 跳转页)和状态码 302 被当成 operation 的成功结果交回 LLM,模型会得到与业务成功不一致的假数据(例如认为已创建资源但实际被重定向),错误诊断也被误导。 修正方向: 将失败判断改为 中等
问题: 触发条件: OpenAPI 文档的某 operation 恰好以 实际影响: 修正方向: 在 较低
问题: 第 334 行把 header 形参值直接 触发条件: OpenAPI spec 声明 header 参数,且 LLM 生成的参数值包含 实际影响: 工具调用不是以 tool error event 失败(会被 修正方向: 在写入 header 前丢弃或替换值中的 较低
问题: 本文件(第 134 行)与英文版 触发条件: 用户按文档快速上手时按文档中的安装流程执行命令, 实际影响: 文档开箱体验失败;用户不得不手动发现需要自行创建 修正方向: 新增 |
| def _validate_path_template(path: Any) -> str: | ||
| if not isinstance(path, str) or not path.startswith("/"): | ||
| raise OpenAPISpecError(f"OpenAPI path must be a path template beginning with '/': {path!r}") | ||
| parsed = urlparse(path) | ||
| if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment: | ||
| raise OpenAPISpecError(f"Invalid OpenAPI path template: {path!r}") | ||
| return path |
There was a problem hiding this comment.
问题: _validate_path_template(第 99-105 行)仅检查 urlparse(path) 的 scheme/netloc/query/fragment,而 /https://evil.example/x 这类以单斜杠开头的路径也能解析出空值,校验被绕过;call() 第 354 行 path.lstrip("/") 后得到 https://evil.example/x,再经 urljoin(self._base_url + "/", ...) 会直接用该值覆盖 base 主机(实测 urljoin("https://api.example.com/v1/", "https://evil.example/x") 返回 https://evil.example/x),请求被发送到 spec 之外的任意主机。allowed_hosts 仅在用户配置时才生效(第 355-357 行),默认值为 None 即无任何主机限制,且新增测试 test_rejects_invalid_path_keys_before_any_request 明确断言 /https://evil.example/items 这类路径会被接受,等于把该绕过固化为预期行为。
触发条件: 用户未显式配置 allowed_hosts(默认 None),且 OpenAPI 文档 paths 键为 /https://<任意主机>/... 形式或包含可被解析为绝对 URL 的路径模板;LLM 要求调用该 operation 时即触发。
实际影响: 工具会将请求发送到 spec 声明主机之外的任意目标(内部地址、云元数据服务如 169.254.169.254 等),形成可利用的 SSRF 向量;_validate_path_template、_validate_base_url 与 _normalize_hostname 的防护在此路径下全部失效,且当 allowed_hosts 未配置时无任何兜底。
修正方向: 在拼接 URL 后对最终目标做强制校验:url = urljoin(...) 之后,解析 urlparse(url).hostname 并核对它属于文档 servers[].url 的主机(或归一化后与配置的 allowed_hosts 之一匹配),不匹配即抛 OpenAPIToolError;同时让 allowed_hosts 默认继承文档 servers 的主机列表,而不是 None,并把 /https://... 用例纳入拒绝路径的测试。
| def _get_declaration(self) -> FunctionDeclaration: | ||
| return FunctionDeclaration( | ||
| description=self.description, | ||
| name=self.name, | ||
| parameters_json_schema=self.input_schema, | ||
| ) |
There was a problem hiding this comment.
问题: OpenAPITool._get_declaration(第 299-304 行)只设置 parameters_json_schema,从不设置 parameters;而 tool_prompt/_xml.py 第 49 行与 tool_prompt/_json.py 第 77-78 行只读取 func_decl.parameters,二者不互通。启用 add_tools_to_prompt=True(Hunyuan 渲染 prompt 注入模式需要)时,_openai_model.py 第 590-591 行生成的工具提示中每个 OpenAPI 工具只剩名称和描述,参数 schema 为空。
触发条件: 用户在 OpenAIModel 上设置 add_tools_to_prompt=True(如 Hunyuan 场景),并把 OpenAPIToolSet 挂到 agent 的 tools 上。
实际影响: 模型在 prompt 中看不到任何参数定义(必填项、嵌套对象、枚举全部丢失),无法生成符合 schema 的调用参数,工具调用会因缺少必填参数或类型错误而持续失败,OpenAPI 工具在该模式下实际不可用,且无任何报错提示。
修正方向: 扩展 tool_prompt 的序列化器,使其在 func_decl.parameters 为 None 时回退读取 func_decl.parameters_json_schema(或让 _get_declaration 同时填充 parameters,二选一并在两处保持一致);并在测试中补充 add_tools_to_prompt=True 渲染 OpenAPI 工具的用例。
| def __init__(self, *, operation_id: str, status_code: int, content: Any): | ||
| self.content = content | ||
| self.operation_id = operation_id | ||
| self.status_code = status_code | ||
| super().__init__(f"OpenAPI operation '{operation_id}' returned HTTP {status_code}: {content!r}") |
There was a problem hiding this comment.
问题: OpenAPIHTTPError.__init__(第 53-57 行)把未经裁剪的完整响应体 content!r 拼进异常消息,而成功路径的 tool 响应会经过 _clip_tool_response_text 裁剪(_openai_model.py 第 719/731 行,tool_response_clip_chars 可配置);错误路径经 ToolsProcessor._create_error_event 原样转发,模型侧错误消息同样不经过裁剪。
触发条件: 被调用的 operation 返回 4xx/5xx,且响应体较大(如网关错误页、长堆栈或超大 JSON 错误体)。
实际影响: 完整错误体(数十 KB 甚至更大)被注入 tool error message 并进入下一轮 LLM 请求,直接膨胀模型上下文、增加 token 成本,长响应还可能挤占上下文窗口导致后续对话质量下降。
修正方向: 在 OpenAPIHTTPError 构造时对 content 做截断(例如保留前 N 字符并追加 ...[TRUNCATED] 标记),或复用 _clip_tool_response_text 的裁剪逻辑,使错误路径与成功路径的资源消耗一致。
| try: | ||
| response = await self._client.request(self._method, url, **request_kwargs) | ||
| except httpx.HTTPError as error: | ||
| raise OpenAPIToolError(f"OpenAPI operation '{self.name}' request failed: {error}") from error | ||
|
|
||
| content = _response_content(response) | ||
| if response.is_error: | ||
| raise OpenAPIHTTPError(operation_id=self.name, status_code=response.status_code, content=content) | ||
| return { | ||
| "content": content, | ||
| "content_type": response.headers.get("content-type", ""), | ||
| "status_code": response.status_code, | ||
| } |
There was a problem hiding this comment.
问题: 第 366-372 行仅用 response.is_error 判断失败,而 httpx 中 3xx 的 is_error 为 False;同时 OpenAPIToolSet.__init__(第 400 行)创建 httpx.AsyncClient 时未设置 follow_redirects(默认 False),301/302/307/308 会被当作成功响应返回。
触发条件: 服务器返回任何 3xx(重定向、未修改、缓存命中)且 Location 指向的端点未被跟随时,或返回 3xx 错误体时。
实际影响: 重定向响应体(如 HTML 跳转页)和状态码 302 被当成 operation 的成功结果交回 LLM,模型会得到与业务成功不一致的假数据(例如认为已创建资源但实际被重定向),错误诊断也被误导。
修正方向: 将失败判断改为 response.is_error or response.status_code >= 300(或 status_code 不在 2xx 即抛 OpenAPIHTTPError),并显式决定 follow_redirects 策略(跟随时需注意重定向目标是否越出 allowed_hosts 约束);补充 3xx 响应的测试用例。
| @staticmethod | ||
| def _validate_operation_id(operation_id: Any, method: str, path: str, seen: set[str]) -> None: | ||
| location = f"{method.upper()} {path}" | ||
| if not isinstance(operation_id, str) or not operation_id: | ||
| raise OpenAPISpecError(f"OpenAPI operation {location} is missing operationId") | ||
| if not _OPERATION_ID_PATTERN.fullmatch(operation_id): | ||
| raise OpenAPISpecError( | ||
| f"Unsafe OpenAPI operationId {operation_id!r} at {location}; " | ||
| "use 1-64 letters, digits, underscores, or hyphens, starting with a letter or underscore") | ||
| if operation_id in seen: |
There was a problem hiding this comment.
问题: _validate_operation_id(第 453-462 行)只按 ^[A-Za-z_][A-Za-z0-9_-]{0,63}$ 校验 operationId 的字面格式,不拒绝框架保留名;而 transfer_to_agent 与 set_model_response 在框架内按字符串特殊处理:_history_processor.py 第 289-291 行会把名为 transfer_to_agent 的 function_call/function_response 直接过滤出会话历史,_output_schema_processor.py 第 96-101 行与 _llm_agent.py 第 631-638 行会把任何名为 set_model_response 的 function_response 当作模型的最终 JSON 响应,并据此结束 agent 执行。
触发条件: OpenAPI 文档的某 operation 恰好以 transfer_to_agent 或 set_model_response 作为 operationId(格式校验允许),且被 LLM 调用。
实际影响: transfer_to_agent 命名的工具调用及结果会从历史中消失,破坏多轮对话上下文;set_model_response 命名的工具调用会让 agent 把 HTTP 响应体当作最终回答立即结束运行,对话流程被劫持,其余工具链执行被截断,用户得到错误且不完整的答案。
修正方向: 在 _validate_operation_id 中增加保留名黑名单(如 transfer_to_agent、set_model_response 等框架特殊处理的名字),检测到即抛 OpenAPISpecError;或在工具构建时对冲突名加前缀/重命名,并补充相应测试。
| response = await self._client.request(self._method, url, **request_kwargs) | ||
| except httpx.HTTPError as error: |
There was a problem hiding this comment.
问题: 第 334 行把 header 形参值直接 str(value) 写入 header,未做 CRLF 校验;第 361-362 行仅捕获 httpx.HTTPError,而 httpx 底层发生 CRLF 注入时会抛 httpcore.LocalProtocolError(不是 httpx.HTTPError 子类),异常会穿透 except 向上传播。
触发条件: OpenAPI spec 声明 header 参数,且 LLM 生成的参数值包含 \r\n(或经 prompt injection 诱导)。
实际影响: 工具调用不是以 tool error event 失败(会被 ToolsProcessor 捕获并喂回 LLM),而是让异常冒泡击穿 agent 执行流程,导致整个 agent 运行崩溃;正常情况下可能基于头部错误注入 HTTP 头。
修正方向: 在写入 header 前丢弃或替换值中的 \r/\n(或复用 httpx 的 header 校验逻辑),同时把异常捕获扩展为 (httpx.HTTPError, httpcore.LocalProtocolError),保证请求层错误统一降级为 OpenAPIToolError。
| cp .env.example .env | ||
| python run_agent.py | ||
| ``` | ||
|
|
There was a problem hiding this comment.
问题: 本文件(第 134 行)与英文版 docs/mkdocs/en/openapi_tools.md(第 137 行)都指导先执行 cp .env.example .env,但本次提交只包含 examples/openapi_tools/.env,没有 .env.example(git ls-files 确认仓库内不存在该文件)。
触发条件: 用户按文档快速上手时按文档中的安装流程执行命令,cp 报「文件不存在」,首次体验即中断。
实际影响: 文档开箱体验失败;用户不得不手动发现需要自行创建 .env,或误以为示例不可用,影响新特性推广。
修正方向: 新增 examples/openapi_tools/.env.example(字段与现有 .env 一致,值为空占位符),或把文档和示例 README 中的命令改为直接编辑 examples/openapi_tools/.env。
AI Code Review审查结论不通过 审查范围:base b6fc4f3..head c4bd41d,计划为「feat(tools): add OpenAPI toolset」。新增 trpc_agent_sdk/tools/_openapi_tools.py(OpenAPI 3.x → BaseToolSet 转换:局部 $ref 解析、参数收集、servers 选择、nullable 处理、base_url/allowed_hosts 校验、httpx 客户端与 close 生命周期、输入 schema 生成)、_openai_model.py 工具参数透传分支、569 行单测、a2a 之外的 OpenAPIToolSet 导出与示例/文档。 计划符合性:整体功能齐全(工具构建、参数/requestBody、$ref、servers 优先级、operationId 过滤、allowlist、错误类型划分、测试和文档齐备),但与周边组件的契约存在多处未对齐。 主要风险:(1)SEVERE——A2A 服务必调的 initialize() 卡片构建流程会对 agent.tools 中所有 BaseToolSet 调用 close(),而 OpenAPIToolSet 默认自建 httpx 客户端且 close() 后不重建,执行器复用同一工具集导致每次 OpenAPI 调用抛未捕获的 RuntimeError,A2A+OpenAPI 确定性不可用(经代码路径与 httpx 实测双重确认);(2)nullable:true 被改成 type:["X","null"] 数组并原样透传所有后端,Anthropic/Deepseek/Vertex(经 LiteLLM) 存在 400 拒绝风险(OpenAI 主链路接受该形式);(3)_get_declaration 只填 parameters_json_schema,add_tools_to_prompt(Hunyuan 强制)与 Claude 服务端路径读到空 schema;(4)urljoin 点段折叠使路径参数中的 .. 可逃逸操作声明范围;(5)SimpleCookie 转义损坏 cookie 值且超大 Cookie 头抛未捕获 ValueError。 测试充分性:14 个工具集单测覆盖构建/参数/过滤/错误/ref 基础场景,2 个模型层透传测试;但缺少 nullable、add_tools_to_prompt、cookie 已编码、点段逃逸、超大 header、A2A 生命周期复用等关键场景,未使用任何集成测试覆盖 a2a 与 OpenAPIToolSet 的组合,测试无法拦截上述缺陷。 门禁结论:REVIEW_CODE_STATE_FAILED。存在 SEVERE 级阻断问题(A2A 生命周期关闭共享客户端);其余 MODERATE/LOW 问题也应在本变更合入前处理。 发现的问题严重
问题: 触发条件: 默认构造 实际影响: 每次 OpenAPI 调用在 修正方向: 让 中等
问题: 触发条件: specs 中任意参数或 requestBody schema 声明 实际影响: OpenAI 主链路接受 修正方向: 按后端规范化 nullable:默认保留 中等
问题: 触发条件: (a) 配置 实际影响: 模型在 prompt 中看到的工具参数为 修正方向: 在 中等
问题: 触发条件: 操作路径形如 实际影响: 请求被静默发往同一主机上操作声明之外的端点(如管理接口),既是资源语义错误也构成范围内越权;且 修正方向: 在替换路径参数后、组装 URL 前拒绝含 中等
问题: 用 触发条件: 操作声明 cookie 参数且参数值含 实际影响: 服务端收到被转义的损坏 cookie(会话/认证参数失效、身份校验失败);超大 Cookie 头则抛出未包装的原始 修正方向: 不再使用 较低
问题: 触发条件: 模型或调用方传入含 实际影响: 上层 runner/执行器收到未预期的异常类型,错误信息晦涩;直接使用 修正方向: 将 较低
问题: 触发条件: 用户 实际影响: 请求静默发往错误的路径(丢失 修正方向: 在 较低
问题: 构造 触发条件: 用户提供自建客户端并同时配置 实际影响: 请求以未认证/默认超时发出,得到 401 或超时故障,但原因无法从代码路径察觉,排障成本高。 修正方向: 在 较低
问题: 触发条件: 对接的 OpenAPI 服务返回大体积响应(大列表、导出文件、日志聚合等接口),模型工具结果随之整体进入上下文。 实际影响: 单次工具调用即可向模型上下文注入数 MB 文本,快速耗尽上下文窗口导致请求失败或成本剧增,存在资源放大风险。 修正方向: 为 较低
问题: 文档 触发条件: 用户按文档操作时 实际影响: 示例无法按文档引导运行,新用户上手即遇到失败,示例可复现性差。 修正方向: 提交 |
| async def close(self) -> None: | ||
| if self._owns_client: | ||
| await self._client.aclose() |
There was a problem hiding this comment.
问题: OpenAPIToolSet.close() 在默认自建客户端(_owns_client=True)时直接 aclose() 内部共享的 httpx.AsyncClient,且 get_tools() 不做任何重建;而 A2A 服务端的 AgentCard 构建流程会对 agent.tools 中所有 BaseToolSet 调用 await toolset.close()(server/a2a/_agent_card_builder.py 与 server/a2a_v1/_agent_card_builder.py 的 _build_tool_skills 均如此),执行器随后复用同一个 agent/工具集实例发起请求。
触发条件: 默认构造 OpenAPIToolSet(不传 client=)挂载到 LlmAgent 后部署 A2A 服务:a2a_svc.initialize() 是文档要求的必调步骤,其内部构建 AgentCard 时即关闭该工具集的客户端;之后 _create_executor 用同一 agent 的同一个 OpenAPIToolSet 执行任何工具调用。
实际影响: 每次 OpenAPI 调用在 self._client.request() 处抛出未被 except httpx.HTTPError 捕获的原始 RuntimeError("Cannot send a request, as the client has been closed.")(沙箱实测 RuntimeError 不是 httpx.HTTPError 子类),工具调用永久性、确定性失败,A2A 与 OpenAPI 组合功能完全不可用;对比 MCPToolSet 会在每次 get_tools() 重建会话,该缺陷是本次变更引入的独有行为。
修正方向: 让 close() 只释放引用并在 call() 前检测 client.is_closed 时惰性重建客户端,或使 A2A 卡片构建只提取工具元信息、不关闭工具集资源,与 MCPToolSet 的会话级重建策略保持一致。
| if nullable: | ||
| schema_type = normalized.get("type") | ||
| if isinstance(schema_type, str): | ||
| normalized["type"] = [schema_type, "null"] |
There was a problem hiding this comment.
问题: _normalize_schema 将 OpenAPI 的 nullable: true 直接改写为 type: [X, "null"] 数组(嵌套属性经递归同样展开),随后 _openai_model.py 本变更新增的分支把它经 parameters_json_schema 不加任何规范化地透传给所有后端。
触发条件: specs 中任意参数或 requestBody schema 声明 nullable: true,且模型后端为 Anthropic(_anthropic_model.py:346-348 将 parameters_json_schema 原样填入 input_schema)、Deepseek 或经 LiteLLM 路由到 Gemini/Vertex 的配置。
实际影响: OpenAI 主链路接受 ["string","null"](官方文档将其作为可选字段的标准写法),但 Anthropic 服务端对 input_schema 做严格校验、Deepseek 对 null/联合形式有已知 400 拒绝记录、Vertex 要求 nullable: true 而非独立 null 分支(litellm 曾有专门修复),这些后端会以 400 拒绝整个 tools 列表,工具注册失败或会话不可用。
修正方向: 按后端规范化 nullable:默认保留 nullable 语义(如转成 anyOf 或保留 nullable 键),在模型层转换;或对已知严格后端降级为不含 null 分支的 schema,避免把 OpenAPI 方言原样发给所有后端。
| def _get_declaration(self) -> FunctionDeclaration: | ||
| return FunctionDeclaration( | ||
| description=self.description, | ||
| name=self.name, | ||
| parameters_json_schema=self.input_schema, | ||
| ) |
There was a problem hiding this comment.
问题: _get_declaration() 只设置 parameters_json_schema=self.input_schema,google Schema 类型的 parameters 字段保持 None;而 tool_prompt/_json.py:77-80、tool_prompt/_xml.py:117-118 以及 server/agents/claude/_claude_agent.py:566 都只读取 func_decl.parameters,为空时生成空 schema。
触发条件: (a) 配置 OpenAIModel(add_tools_to_prompt=True)(文档化用法,且 Hunyuan 适配器 requires_add_tools_to_prompt() 强制该模式,禁用会抛 ValueError),_openai_model.py:590-592 将工具渲染进 system prompt;(b) 通过 Claude 服务端路径加载含 OpenAPI 工具集的 agent。
实际影响: 模型在 prompt 中看到的工具参数为 {"type": "object", "properties": {}}(XML 路径则为空 <parameters> 块),按此 schema 生成并解析工具调用,无法产出符合真实参数结构的参数,OpenAPI 工具在这些路径下调用质量严重下降甚至不可用,且测试只断言了 parameters_json_schema,未覆盖该缺口。
修正方向: 在 _get_declaration() 中同时由 input_schema 构造 parameters=Schema(...),或让 tool_prompt 构建器与 _claude_agent.py 在 parameters 为空时回退读取 parameters_json_schema。
| elif self._request_body_required: | ||
| raise OpenAPIToolError(f"Missing required argument 'request_body' for operation '{self.name}'") | ||
|
|
||
| url = urljoin(self._base_url + "/", path.lstrip("/")) |
There was a problem hiding this comment.
问题: urljoin(self._base_url + "/", path.lstrip("/")) 会按相对路径规则解析点段:路径参数值经 quote(..., safe="") 后 .. 原样保留,urljoin 将其折叠,使最终请求路径脱离操作声明范围。
触发条件: 操作路径形如 /items/{id},参数值由模型(不受信输入)提供为 ..、../admin 等——沙箱实测 urljoin("https://api.example.com/v1/", "pets/../../admin") → https://api.example.com/admin;allowed_hosts 只校验主机不校验路径,主机未变即可通过,未配置 allowed_hosts(默认 None)时更无约束。
实际影响: 请求被静默发往同一主机上操作声明之外的端点(如管理接口),既是资源语义错误也构成范围内越权;且 .. 值不会被拒绝或告警,问题难以察觉。
修正方向: 在替换路径参数后、组装 URL 前拒绝含 ../. 段的路径,或改用不解析点段的 urllib.parse.urlunsplit 组装,并将最终 URL 的 path 与规范化的操作路径比对。
| if cookies: | ||
| headers["Cookie"] = cookies.output(header="", sep=";").strip() |
There was a problem hiding this comment.
问题: 用 SimpleCookie 序列化 cookie 参数会按 RFC 6265 转义特殊字符(沙箱实测 ; → \073、空格等均被转义),服务端通常不会解码回原值;且全部 cookie 拼进单个 Cookie 头,htxt 对超过约 4KB 的单头值直接抛原始 ValueError(非 httpx.HTTPError,不被 L360-363 的异常包装捕获)。
触发条件: 操作声明 cookie 参数且参数值含 ;、空格、非 ASCII 字符(模型常见的多 cookie 字符串如 session=abc; theme=dark 是最自然的触发输入);或 cookie 数量多/值大(沙箱实测 100 个 cookie 序列化 5588 字节即触发 httpx 报错)。
实际影响: 服务端收到被转义的损坏 cookie(会话/认证参数失效、身份校验失败);超大 Cookie 头则抛出未包装的原始 ValueError,工具调用以裸异常终止,与文档承诺的 OpenAPIToolError 错误契约不符。
修正方向: 不再使用 SimpleCookie,按参数直接拼装 Name=value 并对整个 cookie 值做 URL 编码;同时对 header 总大小设上限或把异常捕获扩展为 (httpx.HTTPError, ValueError, TypeError) 统一转成 OpenAPIToolError。
| try: | ||
| response = await self._client.request(self._method, url, **request_kwargs) | ||
| except httpx.HTTPError as error: | ||
| raise OpenAPIToolError(f"OpenAPI operation '{self.name}' request failed: {error}") from error |
There was a problem hiding this comment.
问题: call() 只在 try 内执行 self._client.request() 且仅包装 except httpx.HTTPError,但 httpx 在构造 Request 时对 json= 参数执行 json_dumps,request_body 含 datetime 等不可序列化对象时抛出 TypeError(沙箱实测 issubclass(TypeError, httpx.HTTPError) == False),客户端已关闭时抛出 RuntimeError,都会原样漏出。
触发条件: 模型或调用方传入含 datetime/自定义对象等非 JSON 可序列化值的 request_body;或客户端被关闭后再调用(与 A2A 生命周期问题叠加时会放大其影响面)。
实际影响: 上层 runner/执行器收到未预期的异常类型,错误信息晦涩;直接使用 tool.call() 的用户代码捕获不到文档承诺的 OpenAPIToolError,错误契约被破坏。
修正方向: 将 try 范围前移到参数组装与序列化阶段,except (httpx.HTTPError, TypeError, ValueError, RuntimeError),对不可序列化数据给出明确提示后转为 OpenAPIToolError。
| def _validate_base_url(value: str) -> str: | ||
| parsed = urlparse(value) | ||
| if parsed.scheme not in {"http", "https"} or not parsed.hostname: | ||
| raise OpenAPISpecError(f"OpenAPI base URL must be an absolute HTTP(S) URL: {value!r}") | ||
| if parsed.username or parsed.password or parsed.fragment: | ||
| raise OpenAPISpecError("OpenAPI base URL must not contain credentials or a fragment") | ||
| return value.rstrip("/") |
There was a problem hiding this comment.
问题: _validate_base_url 只拒绝凭据与 fragment,允许 URL 携带 query 字符串;而 call() 的 urljoin 拼接在 base 含 query 时会把 query 丢弃并替换路径最后一段(沙箱实测 urljoin("https://x/v1?env=prod/", "items") → https://x/items)。
触发条件: 用户 base_url 或 OpenAPI servers 条目 URL 包含查询串(如签名/环境参数 https://api.example.com/v1?env=prod)时,该工具集所有操作全部路由到错误地址。
实际影响: 请求静默发往错误的路径(丢失 /v1 前缀与整段 query),出现难以排查的 404/401 类故障。
修正方向: 在 _validate_base_url 中显式拒绝带 query 的 base URL 并给出清晰错误;或改用 urlunsplit 显式组装 path 与 query,不依赖 urljoin 的隐式解析。
| if client is not None and transport is not None: | ||
| raise OpenAPISpecError("Provide either client or transport, not both") | ||
| self.document = load_openapi_document(source) | ||
| self._allowed_hosts = _normalize_allowed_hosts(allowed_hosts) | ||
| self._owns_client = client is None | ||
| self._client = client or httpx.AsyncClient(auth=auth, timeout=timeout, transport=transport) |
There was a problem hiding this comment.
问题: 构造 OpenAPIToolSet 时若同时传入 client=,auth/timeout 会被静默忽略(它们只应用于自建客户端分支),没有任何错误或告警。
触发条件: 用户提供自建客户端并同时配置 auth=BasicAuth(...) 或 timeout=,期望其生效。
实际影响: 请求以未认证/默认超时发出,得到 401 或超时故障,但原因无法从代码路径察觉,排障成本高。
修正方向: 在 client 与 auth/timeout 同时传入时抛出 OpenAPISpecError 明确互斥,或在 call() 中把 auth 应用到请求级。
| def _response_content(response: httpx.Response) -> Any: | ||
| if not response.content: | ||
| return None | ||
| content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() | ||
| if content_type == "application/json" or content_type.endswith("+json"): | ||
| try: | ||
| return response.json() | ||
| except ValueError: | ||
| return response.text | ||
| return response.text |
There was a problem hiding this comment.
问题: _response_content 对响应体没有任何大小上限,OpenAI 路径的 tool_response_clip_chars 裁剪默认配置为 0(关闭),Anthropic 路径完全没有裁剪机制。
触发条件: 对接的 OpenAPI 服务返回大体积响应(大列表、导出文件、日志聚合等接口),模型工具结果随之整体进入上下文。
实际影响: 单次工具调用即可向模型上下文注入数 MB 文本,快速耗尽上下文窗口导致请求失败或成本剧增,存在资源放大风险。
修正方向: 为 _response_content 提供可配置的 max_chars 上限(参照 webfetch 的 max_length 模式),超限时截断并标注截断标记。
| @@ -0,0 +1,4 @@ | |||
| TRPC_AGENT_API_KEY= | |||
There was a problem hiding this comment.
问题: 文档 docs/mkdocs/zh/openapi_tools.md 与 docs/mkdocs/en/openapi_tools.md 均指引用户执行 cp .env.example .env,但本变更只提交了值为空的 .env 文件,仓库中没有 .env.example。
触发条件: 用户按文档操作时 cp .env.example .env 直接失败;按仓库自带 .env 直接运行示例时 TRPC_AGENT_API_KEY 等三个必填环境变量为空。
实际影响: 示例无法按文档引导运行,新用户上手即遇到失败,示例可复现性差。
修正方向: 提交 .env.example 模板(或改为「编辑 .env」的指引),并在示例启动前对缺失环境变量做显式校验与提示。
No description provided.