From c4bd41d773f8f37c1702ab2436da848258f86686 Mon Sep 17 00:00:00 2001 From: raychen <815315825@qq.com> Date: Tue, 22 Sep 2026 15:18:33 +0800 Subject: [PATCH] feat(tools): add OpenAPI toolset --- docs/mkdocs/en/openapi_tools.md | 142 +++++ docs/mkdocs/zh/openapi_tools.md | 139 +++++ examples/openapi_tools/.env | 4 + examples/openapi_tools/README.md | 139 +++++ examples/openapi_tools/agent/__init__.py | 14 + examples/openapi_tools/agent/agent.py | 38 ++ examples/openapi_tools/agent/config.py | 19 + examples/openapi_tools/agent/prompts.py | 15 + examples/openapi_tools/agent/tools.py | 50 ++ examples/openapi_tools/openapi.yaml | 45 ++ examples/openapi_tools/run_agent.py | 71 +++ mkdocs.yml | 2 + tests/models/test_openai_model_ext.py | 61 ++ .../tools/openapi_tools/test_openapi_tools.py | 569 ++++++++++++++++++ trpc_agent_sdk/models/_openai_model.py | 9 +- trpc_agent_sdk/tools/__init__.py | 12 + trpc_agent_sdk/tools/_openapi_tools.py | 559 +++++++++++++++++ 17 files changed, 1886 insertions(+), 2 deletions(-) create mode 100644 docs/mkdocs/en/openapi_tools.md create mode 100644 docs/mkdocs/zh/openapi_tools.md create mode 100644 examples/openapi_tools/.env create mode 100644 examples/openapi_tools/README.md create mode 100644 examples/openapi_tools/agent/__init__.py create mode 100644 examples/openapi_tools/agent/agent.py create mode 100644 examples/openapi_tools/agent/config.py create mode 100644 examples/openapi_tools/agent/prompts.py create mode 100644 examples/openapi_tools/agent/tools.py create mode 100644 examples/openapi_tools/openapi.yaml create mode 100644 examples/openapi_tools/run_agent.py create mode 100644 tests/tools/openapi_tools/test_openapi_tools.py create mode 100644 trpc_agent_sdk/tools/_openapi_tools.py diff --git a/docs/mkdocs/en/openapi_tools.md b/docs/mkdocs/en/openapi_tools.md new file mode 100644 index 000000000..138d95e1f --- /dev/null +++ b/docs/mkdocs/en/openapi_tools.md @@ -0,0 +1,142 @@ +# OpenAPI Tools + +`OpenAPIToolSet` converts operations in an OpenAPI 3.x document into standard tRPC-Agent `BaseTool` instances. It loads a Python mapping or local JSON/YAML file, derives each tool name from `operationId`, exposes JSON input schemas to the model, and executes calls asynchronously with `httpx`. + +## Use cases + +OpenAPI Tools are useful when a system already exposes REST APIs and maintains +an OpenAPI document, but implementing a separate `FunctionTool` for every +operation would duplicate existing contracts. Typical examples include: + +- CRM customer search and activity records; +- ticket lookup, creation, and status updates; +- product, inventory, and order services; +- internal approval, CMDB, and operations platforms; +- SaaS products that publish OpenAPI specifications. + +The feature reuses the existing API contract: + +```text +OpenAPI document +→ operationId and parameter schemas +→ Agent-visible Tools +→ model selects a Tool and generates arguments +→ asynchronous HTTP request +→ Tool Result returned to the model +``` + +It does not replace the Agent. `OpenAPIToolSet` adapts APIs into Tools, while +`LlmAgent` still interprets user intent, chooses operations, prepares arguments, +and explains responses. + +## Basic usage + +```python +from trpc_agent_sdk.tools import OpenAPIToolSet + +toolset = OpenAPIToolSet( + "openapi.yaml", + base_url="https://api.example.com/v1", + operation_ids=["get_pet", "create_pet"], + static_headers={"Authorization": "Bearer secret"}, + allowed_hosts=["api.example.com"], + timeout=20.0, +) + +tools = await toolset.get_tools() +``` + +Pass `toolset` directly in an agent's `tools` list. Call `await toolset.close()` if no runner owns its lifecycle. An existing `httpx.AsyncClient` can be supplied with `client`; when supplied, the caller retains ownership. `transport` is also available for an owned custom transport such as `httpx.MockTransport`. + +`auth` accepts an `httpx.Auth` implementation. `static_headers` are applied after operation arguments, so trusted application headers cannot be replaced by model-generated header parameters. + +## Conversion logic + +For every HTTP operation: + +1. `operationId` becomes the tool name. Missing, duplicate, or unsafe IDs are rejected. +2. Path-level and operation-level parameters are combined. Operation-level entries override matching `(name, location)` entries. +3. Path, query, header, and cookie parameters become top-level tool arguments. +4. an `application/json` request body becomes the `request_body` argument. +5. Required parameters and request bodies populate the generated JSON Schema `required` list. +6. Local `$ref` JSON Pointers are resolved before schema generation. +7. The base URL is chosen from `base_url`, operation servers, path servers, then document servers. + +Successful calls return: + +```python +{ + "status_code": 200, + "content_type": "application/json", + "content": {"id": "pet-1"}, +} +``` + +JSON media types, including `+json`, are decoded. Other responses are returned as text and empty responses as `None`. HTTP 4xx/5xx responses raise `OpenAPIHTTPError`; transport, argument, and conversion failures use clear `OpenAPIToolError` or `OpenAPISpecError` exceptions. + +## Selection and filtering + +Use `operation_ids` to select a fixed subset while converting. Unknown requested IDs are rejected. The standard `tool_filter` and `is_include_all_tools` arguments remain available for context-time filtering through the normal toolset interface. + +## Security and SSRF + +Treat OpenAPI documents as executable configuration: + +- Prefer checked-in local specifications. Remote specification URL loading is intentionally not supported; fetch and validate a trusted document in application code before passing a mapping. +- Set `base_url` from trusted configuration instead of accepting it from users. +- Set `allowed_hosts` to constrain the final request host. Redirect behavior remains controlled by the supplied `httpx.AsyncClient`; the default client does not follow redirects. +- Inject credentials with `auth` or `static_headers`, and do not place secrets in the OpenAPI file. +- Review operations before exposing them to a model, especially destructive methods. +- Apply network egress controls as the final SSRF boundary. Host allowlists do not replace DNS and network-layer controls. + +Base URLs must be absolute HTTP(S) URLs and cannot contain embedded credentials or fragments. + +## Supported subset and limitations + +Supported: + +- OpenAPI 3.x documents from Python mappings and local JSON/YAML files +- HTTP operations with safe, unique `operationId` values +- Path, query, header, and cookie parameters +- JSON request bodies +- Local, non-circular `$ref` references +- server variable defaults +- JSON, text, and empty response bodies + +Not currently supported: + +- Swagger/OpenAPI 2.x +- remote or external `$ref` references and remote specification loading +- circular schemas +- non-JSON request bodies, multipart uploads, callbacks, links, or webhooks +- full OpenAPI parameter serialization styles such as `deepObject` +- automatic interpretation of OpenAPI `securitySchemes` +- response-schema validation + +## Complete Agent example + +The [OpenAPI tools example](../../../examples/openapi_tools/README.md) follows +the same complete Agent structure as Quickstart: + +```text +root_agent (LlmAgent) +└── OpenAPIToolSet + ├── get_pet + └── create_pet +``` + +A real model decides whether to call `get_pet` or `create_pet`, and the example +prints generated arguments, Tool Results, and final Agent responses. The Pet +API itself uses an in-process `httpx.MockTransport`, so no external business +service is contacted. + +Copy and configure the environment before running: + +```bash +cd examples/openapi_tools +cp .env.example .env +python run_agent.py +``` + +For production, replace the Mock Transport with the real HTTP API and use +`operation_ids` to expose only reviewed operations. diff --git a/docs/mkdocs/zh/openapi_tools.md b/docs/mkdocs/zh/openapi_tools.md new file mode 100644 index 000000000..c095e6e0a --- /dev/null +++ b/docs/mkdocs/zh/openapi_tools.md @@ -0,0 +1,139 @@ +# OpenAPI 工具 + +`OpenAPIToolSet` 可将 OpenAPI 3.x 文档中的操作转换为标准 tRPC-Agent `BaseTool`。它支持 Python 字典或本地 JSON/YAML 文件,以 `operationId` 生成工具名,向模型暴露 JSON 输入 Schema,并通过 `httpx` 异步发起请求。 + +## 使用场景 + +OpenAPI Tools 适合已经有 REST API 和 OpenAPI 文档,但不希望逐个开发 +`FunctionTool` 的系统,例如: + +- CRM 客户查询和跟进记录; +- 工单查询、创建和状态更新; +- 商品、库存和订单服务; +- 企业内部审批、CMDB 或运维平台; +- 由第三方提供 OpenAPI 规范的 SaaS 服务。 + +其核心价值是复用现有 API 契约: + +```text +OpenAPI 文档 +→ operationId 和参数 Schema +→ Agent 可见的 Tool +→ 模型选择 Tool 并生成参数 +→ 异步 HTTP 请求 +→ Tool Result 返回模型 +``` + +它不会替代 Agent。`OpenAPIToolSet` 负责 API 到 Tool 的适配,`LlmAgent` +仍负责理解用户意图、选择 API、组合参数并解释响应。 + +## 基本用法 + +```python +from trpc_agent_sdk.tools import OpenAPIToolSet + +toolset = OpenAPIToolSet( + "openapi.yaml", + base_url="https://api.example.com/v1", + operation_ids=["get_pet", "create_pet"], + static_headers={"Authorization": "Bearer secret"}, + allowed_hosts=["api.example.com"], + timeout=20.0, +) + +tools = await toolset.get_tools() +``` + +可将 `toolset` 直接放入 Agent 的 `tools` 列表。如果没有 Runner 管理生命周期,请调用 `await toolset.close()`。也可传入已有的 `httpx.AsyncClient`;此时客户端仍由调用方关闭。`transport` 可用于让 ToolSet 管理自定义传输,例如 `httpx.MockTransport`。 + +`auth` 接受 `httpx.Auth` 实现。`static_headers` 在操作参数之后写入,因此模型生成的 Header 参数不能覆盖应用注入的可信 Header。 + +## 转换逻辑 + +每个 HTTP 操作按以下规则转换: + +1. `operationId` 成为工具名;缺失、重复或不安全的 ID 会立即报错。 +2. 合并 Path Item 与 Operation 参数;相同 `(name, location)` 的 Operation 参数优先。 +3. path、query、header、cookie 参数成为顶层工具参数。 +4. `application/json` 请求体成为 `request_body` 参数。 +5. 必填参数和必填请求体进入生成 JSON Schema 的 `required`。 +6. 生成 Schema 前解析本地 `$ref` JSON Pointer。 +7. Base URL 按 `base_url`、Operation servers、Path servers、文档 servers 的顺序选择。 + +成功调用返回: + +```python +{ + "status_code": 200, + "content_type": "application/json", + "content": {"id": "pet-1"}, +} +``` + +JSON 和 `+json` 媒体类型会被解析;其他响应返回文本,空响应返回 `None`。HTTP 4xx/5xx 抛出 `OpenAPIHTTPError`;传输、参数和转换错误会使用清晰的 `OpenAPIToolError` 或 `OpenAPISpecError`。 + +## 操作选择与过滤 + +使用 `operation_ids` 在转换时选择固定操作子集。指定不存在的 ID 会报错。标准 `tool_filter` 与 `is_include_all_tools` 参数仍可通过普通 ToolSet 接口实现运行时过滤。 + +## 安全与 SSRF + +应将 OpenAPI 文档视为可执行配置: + +- 优先使用代码仓库中受控的本地规范。为降低 SSRF 风险,本功能不支持直接从 URL 加载规范;如有需要,应在应用层获取并校验可信文档,再传入字典。 +- 使用可信配置设置 `base_url`,不要接受用户输入。 +- 使用 `allowed_hosts` 限制最终请求主机。重定向行为由传入的 `httpx.AsyncClient` 控制;默认客户端不跟随重定向。 +- 使用 `auth` 或 `static_headers` 注入凭证,不要把密钥写入 OpenAPI 文件。 +- 向模型暴露前审查操作,尤其是具有修改或删除效果的操作。 +- 使用网络出口策略作为最终 SSRF 防线;Host 白名单不能替代 DNS 与网络层控制。 + +Base URL 必须是绝对 HTTP(S) URL,且不能包含内嵌凭证或 Fragment。 + +## 支持范围与限制 + +支持: + +- Python 字典和本地 JSON/YAML 格式的 OpenAPI 3.x +- 具有安全且唯一 `operationId` 的 HTTP 操作 +- path、query、header、cookie 参数 +- JSON 请求体 +- 本地、非循环 `$ref` +- Server 变量默认值 +- JSON、文本和空响应体 + +暂不支持: + +- Swagger/OpenAPI 2.x +- 远程或外部 `$ref`,以及远程规范 URL 加载 +- 循环 Schema +- 非 JSON 请求体、multipart 上传、callback、link 或 webhook +- `deepObject` 等完整 OpenAPI 参数序列化样式 +- 自动解析 OpenAPI `securitySchemes` +- 响应 Schema 校验 + +## 完整 Agent 示例 + +[OpenAPI 工具示例](../../../examples/openapi_tools/README.md)采用与 Quickstart +一致的完整 Agent 结构: + +```text +root_agent (LlmAgent) +└── OpenAPIToolSet + ├── get_pet + └── create_pet +``` + +示例使用真实模型判断应调用 `get_pet` 还是 `create_pet`,并打印模型生成的 +参数、Tool Result 和最终回答。Pet API 通过 `httpx.MockTransport` 在进程内 +模拟,因此不会访问外部业务服务。 + +运行前复制并配置环境变量: + +```bash +cd examples/openapi_tools +cp .env.example .env +python run_agent.py +``` + +生产环境可将 Mock Transport 替换为真实 HTTP API,并通过 `operation_ids` +只暴露经过审查的操作。 diff --git a/examples/openapi_tools/.env b/examples/openapi_tools/.env new file mode 100644 index 000000000..d5abddcab --- /dev/null +++ b/examples/openapi_tools/.env @@ -0,0 +1,4 @@ +TRPC_AGENT_API_KEY= +TRPC_AGENT_BASE_URL= +TRPC_AGENT_MODEL_NAME= + diff --git a/examples/openapi_tools/README.md b/examples/openapi_tools/README.md new file mode 100644 index 000000000..36dd1e3bd --- /dev/null +++ b/examples/openapi_tools/README.md @@ -0,0 +1,139 @@ +# OpenAPI Tools 完整 Agent 示例 + +本示例参考 `examples/quickstart` 的目录结构,展示模型如何调用由 OpenAPI +文档自动生成的 Tool。模型负责判断使用哪个 API、生成参数并解释结果; +`OpenAPIToolSet` 负责把 Tool 调用转换为真实 HTTP 请求。 + +```text +用户请求 +→ LlmAgent 选择 OpenAPI Tool +→ OpenAPIToolSet 生成 HTTP 请求 +→ Pet API 返回结果 +→ Agent 向用户解释结果 +``` + +Pet API 使用 `httpx.MockTransport` 在当前进程中模拟,因此不会访问外部业务 +服务;`LlmAgent` 仍需要配置真实的 OpenAI 兼容模型。 + +## 使用场景 + +适合以下场景: + +- 企业已有大量 REST API,并且已经维护 OpenAPI 3.x 文档; +- 希望快速把 CRM、工单、库存、订单或内部平台接口提供给 Agent; +- 不希望为每个 HTTP 接口重复编写 `FunctionTool`; +- 希望由 OpenAPI Schema 自动约束模型生成的 Path、Query 和 JSON Body; +- 需要通过操作白名单只向模型开放部分安全接口。 + +例如,一个订单系统有数十个查询接口时,可以直接选择 +`get_order`、`list_orders` 和 `query_inventory` 等 `operationId` 生成 Tool, +而不必逐个编写 HTTP 请求代码。 + +## Agent 层级结构 + +```text +root_agent (LlmAgent) +└── OpenAPIToolSet + ├── get_pet + └── create_pet +``` + +## 目录结构 + +```text +openapi_tools/ +├── agent/ +│ ├── agent.py +│ ├── config.py +│ ├── prompts.py +│ └── tools.py +├── .env +├── openapi.yaml +├── README.md +└── run_agent.py +``` + +- `openapi.yaml` 描述 `get_pet` 和 `create_pet` 两个 API。 +- `agent/tools.py` 加载 OpenAPI,并配置 Mock API、Host 白名单和静态 Header。 +- `agent/agent.py` 把 `OpenAPIToolSet` 交给 `LlmAgent`。 +- `run_agent.py` 使用 `Runner` 发送查询和创建宠物的请求,并打印 Tool 调用。 + +## 关键代码 + +根据 OpenAPI 文档创建 ToolSet: + +```python +toolset = OpenAPIToolSet( + "openapi.yaml", + allowed_hosts=["pets.local"], + static_headers={"X-Demo-Token": "local-only"}, + transport=httpx.MockTransport(_mock_api), +) +``` + +将整个 ToolSet 直接交给 Agent: + +```python +agent = LlmAgent( + name="openapi_pet_agent", + model=model, + instruction=INSTRUCTION, + tools=[toolset], +) +``` + +当用户查询 `pet-1` 时,模型调用: + +```text +get_pet(pet_id="pet-1") +``` + +框架转换为: + +```http +GET https://pets.local/v1/pets/pet-1 +``` + +创建宠物时,模型调用 `create_pet`,`request_body` 会成为 HTTP JSON Body。 + +## 环境配置 + +```bash +cd examples/openapi_tools +``` + +填写 OpenAI 兼容模型配置: + +```dotenv +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name +``` + +## 运行 + +从仓库根目录执行: + +```bash +python examples/openapi_tools/run_agent.py +``` + +示例会运行两个独立请求: + +1. 查询 `pet-1`,预期调用 `get_pet`; +2. 创建名为 Pixel 的狗,预期调用 `create_pet`。 + +输出中可以看到模型生成的 Tool 参数、Mock API 返回值和 Agent 最终回答。 + +## 替换为真实 API + +生产使用时: + +1. 将 `openapi.yaml` 替换为经过审查的 OpenAPI 3.x 文档; +2. 删除 `httpx.MockTransport`; +3. 使用可信配置设置 `base_url` 和 `allowed_hosts`; +4. 使用 `auth` 或 `static_headers` 注入认证信息; +5. 通过 `operation_ids` 只开放必要接口。 + +支持范围和 SSRF 安全说明见 +[OpenAPI Tools 文档](../../docs/mkdocs/zh/openapi_tools.md)。 diff --git a/examples/openapi_tools/agent/__init__.py b/examples/openapi_tools/agent/__init__.py new file mode 100644 index 000000000..131c25aba --- /dev/null +++ b/examples/openapi_tools/agent/__init__.py @@ -0,0 +1,14 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""OpenAPI tools example Agent package.""" + +from .agent import create_agent +from .agent import root_agent + +__all__ = [ + "create_agent", + "root_agent", +] diff --git a/examples/openapi_tools/agent/agent.py b/examples/openapi_tools/agent/agent.py new file mode 100644 index 000000000..d06ddbe8f --- /dev/null +++ b/examples/openapi_tools/agent/agent.py @@ -0,0 +1,38 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""LlmAgent configuration for the OpenAPI tools example.""" + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import OpenAIModel + +from .config import get_model_config +from .prompts import INSTRUCTION +from .tools import create_toolset + + +def _create_model() -> LLMModel: + """Create the configured OpenAI-compatible model.""" + api_key, base_url, model_name = get_model_config() + return OpenAIModel( + api_key=api_key, + base_url=base_url, + model_name=model_name, + ) + + +def create_agent() -> LlmAgent: + """Create an Agent backed by tools generated from OpenAPI.""" + return LlmAgent( + description="Queries and updates a pet service through OpenAPI tools.", + instruction=INSTRUCTION, + model=_create_model(), + name="openapi_pet_agent", + tools=[create_toolset()], + ) + + +root_agent = create_agent() diff --git a/examples/openapi_tools/agent/config.py b/examples/openapi_tools/agent/config.py new file mode 100644 index 000000000..f487b47e6 --- /dev/null +++ b/examples/openapi_tools/agent/config.py @@ -0,0 +1,19 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Model configuration for the OpenAPI tools example.""" + +import os + + +def get_model_config() -> tuple[str, str, str]: + """Read the OpenAI-compatible model configuration.""" + api_key = os.getenv("TRPC_AGENT_API_KEY", "") + base_url = os.getenv("TRPC_AGENT_BASE_URL", "") + model_name = os.getenv("TRPC_AGENT_MODEL_NAME", "") + if not api_key or not base_url or not model_name: + raise ValueError("TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, and " + "TRPC_AGENT_MODEL_NAME must be configured.") + return api_key, base_url, model_name diff --git a/examples/openapi_tools/agent/prompts.py b/examples/openapi_tools/agent/prompts.py new file mode 100644 index 000000000..6f30b1dac --- /dev/null +++ b/examples/openapi_tools/agent/prompts.py @@ -0,0 +1,15 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Prompts for the OpenAPI tools example.""" + +INSTRUCTION = """ +You are a pet service assistant. + +Use the API tools generated from the OpenAPI document to query or create pets. +Do not invent API results. When the user asks for one pet, call get_pet. When +the user asks to create a pet, call create_pet with request_body containing +name and species. Summarize the API result clearly for the user. +""".strip() diff --git a/examples/openapi_tools/agent/tools.py b/examples/openapi_tools/agent/tools.py new file mode 100644 index 000000000..30c1a3525 --- /dev/null +++ b/examples/openapi_tools/agent/tools.py @@ -0,0 +1,50 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Create deterministic OpenAPI tools backed by an in-process mock API.""" + +import json +from pathlib import Path + +import httpx + +from trpc_agent_sdk.tools import OpenAPIToolSet + + +def _mock_api(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == "/v1/pets/pet-1": + return httpx.Response( + 200, + json={ + "id": "pet-1", + "name": "Mochi", + "species": "cat", + }, + ) + if request.method == "POST" and request.url.path == "/v1/pets": + body = json.loads(request.content) + return httpx.Response( + 201, + json={ + "id": "pet-2", + **body, + }, + ) + return httpx.Response(404, json={ + "error": "not found", + }) + + +def create_toolset() -> OpenAPIToolSet: + """Load the local specification and attach an in-process HTTP transport.""" + specification = Path(__file__).parents[1] / "openapi.yaml" + return OpenAPIToolSet( + specification, + allowed_hosts=["pets.local"], + static_headers={ + "X-Demo-Token": "local-only", + }, + transport=httpx.MockTransport(_mock_api), + ) diff --git a/examples/openapi_tools/openapi.yaml b/examples/openapi_tools/openapi.yaml new file mode 100644 index 000000000..62dfd0a3a --- /dev/null +++ b/examples/openapi_tools/openapi.yaml @@ -0,0 +1,45 @@ +openapi: 3.0.3 +info: + title: Local Pet API + version: 1.0.0 +servers: + - url: https://pets.local/v1 +paths: + /pets/{pet_id}: + get: + operationId: get_pet + summary: Get one pet by ID + parameters: + - name: pet_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Pet details + /pets: + post: + operationId: create_pet + summary: Create a pet + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PetInput" + responses: + "201": + description: Created pet +components: + schemas: + PetInput: + type: object + properties: + name: + type: string + species: + type: string + required: + - name + - species diff --git a/examples/openapi_tools/run_agent.py b/examples/openapi_tools/run_agent.py new file mode 100644 index 000000000..b49c9f805 --- /dev/null +++ b/examples/openapi_tools/run_agent.py @@ -0,0 +1,71 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Run an Agent that calls tools generated from an OpenAPI document.""" + +import asyncio +import uuid +from pathlib import Path + +from dotenv import load_dotenv + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +load_dotenv(Path(__file__).with_name(".env")) + + +async def run_openapi_agent() -> None: + """Run model-driven query and create operations against the mock API.""" + from agent import root_agent + + runner = Runner( + agent=root_agent, + app_name="openapi_tools_demo", + session_service=InMemorySessionService(), + ) + queries = [ + "Find pet pet-1 and tell me its name and species.", + "Create a dog named Pixel and tell me the new pet ID.", + ] + + try: + for query in queries: + session_id = str(uuid.uuid4()) + has_streamed_text = False + print(f"Session: {session_id}") + print(f"User: {query}") + print("Assistant: ", end="", flush=True) + + async for event in runner.run_async( + user_id="demo_user", + session_id=session_id, + new_message=Content(parts=[Part.from_text(text=query)]), + ): + if not event.content or not event.content.parts: + continue + if event.partial: + for part in event.content.parts: + if part.text and not part.thought: + has_streamed_text = True + print(part.text, end="", flush=True) + continue + for part in event.content.parts: + if part.function_call: + print(f"\n[Invoke Tool: {part.function_call.name}" + f"({part.function_call.args})]") + elif part.function_response: + print(f"\n[Tool Result: {part.function_response.response}]") + elif part.text and not part.thought and not has_streamed_text: + print(part.text, end="", flush=True) + print("\n" + "-" * 60) + finally: + await runner.close() + + +if __name__ == "__main__": + asyncio.run(run_openapi_agent()) diff --git a/mkdocs.yml b/mkdocs.yml index f6a46830c..d6d370c63 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,7 @@ nav: - Task Tools: en/tool_task.md - Goal: en/goal.md - Code Executor: en/code_executor.md + - OpenAPI Tools: en/openapi_tools.md - Skills: en/skill.md - Session & Memory: - Session: en/session.md @@ -83,6 +84,7 @@ nav: - Task 工具: zh/tool_task.md - Goal 工具: zh/goal.md - 代码执行器: zh/code_executor.md + - OpenAPI 工具: zh/openapi_tools.md - Skills: zh/skill.md - Session 与 Memory: - Session: zh/session.md diff --git a/tests/models/test_openai_model_ext.py b/tests/models/test_openai_model_ext.py index c3fbf2b11..4b650a039 100644 --- a/tests/models/test_openai_model_ext.py +++ b/tests/models/test_openai_model_ext.py @@ -628,6 +628,67 @@ def test_with_parameters_schema(self): assert "city" in params["properties"] assert params["required"] == ["city"] + def test_with_parameters_json_schema(self): + """Preserves nested raw JSON Schema used by OpenAPI tools.""" + model = _model() + json_schema = { + "type": "object", + "properties": { + "request_body": { + "type": "object", + "properties": { + "name": { + "type": "string", + }, + "species": { + "type": "string", + }, + }, + "required": ["name", "species"], + }, + }, + "required": ["request_body"], + } + fd = FunctionDeclaration( + name="create_pet", + parameters_json_schema=json_schema, + ) + tool = Tool(function_declarations=[fd]) + + result = model._convert_tools_to_openai_format([tool]) + params = result[0]["function"]["parameters"] + + assert params == json_schema + params["properties"]["request_body"]["required"].append("age") + assert fd.parameters_json_schema == json_schema + + def test_parameters_json_schema_takes_precedence(self): + """Prefers raw JSON Schema when both schema forms are populated.""" + model = _model() + fd = FunctionDeclaration( + name="lookup", + parameters=Schema( + type=Type.OBJECT, + properties={ + "legacy": Schema(type=Type.STRING), + }, + ), + parameters_json_schema={ + "type": "object", + "properties": { + "item_id": { + "type": "string", + }, + }, + "required": ["item_id"], + }, + ) + tool = Tool(function_declarations=[fd]) + + result = model._convert_tools_to_openai_format([tool]) + + assert result[0]["function"]["parameters"] == fd.parameters_json_schema + # --------------------------------------------------------------------------- # _convert_schema_to_openai_format diff --git a/tests/tools/openapi_tools/test_openapi_tools.py b/tests/tools/openapi_tools/test_openapi_tools.py new file mode 100644 index 000000000..06109d191 --- /dev/null +++ b/tests/tools/openapi_tools/test_openapi_tools.py @@ -0,0 +1,569 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. + +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest +import yaml + +from trpc_agent_sdk.tools import OpenAPIHTTPError +from trpc_agent_sdk.tools import OpenAPISpecError +from trpc_agent_sdk.tools import OpenAPIToolError +from trpc_agent_sdk.tools import OpenAPIToolSet +from trpc_agent_sdk.tools import load_openapi_document + + +def _document(paths: dict[str, Any], **extra: Any) -> dict[str, Any]: + return { + "info": { + "title": "Test API", + "version": "1.0.0", + }, + "openapi": "3.0.3", + "paths": paths, + "servers": [{ + "url": "https://api.example.test/v1", + }], + **extra, + } + + +def _operation(operation_id: str = "get_item", **extra: Any) -> dict[str, Any]: + return { + "operationId": operation_id, + "responses": { + "200": { + "description": "OK", + }, + }, + "summary": "Get an item", + **extra, + } + + +async def _run(tool: Any, args: dict[str, Any]) -> Any: + return await tool._run_async_impl(tool_context=None, args=args) + + +@pytest.mark.asyncio +async def test_builds_schema_from_parameters_body_and_local_refs(): + document = _document( + { + "/items/{item_id}": { + "get": + _operation( + parameters=[ + { + "$ref": "#/components/parameters/ItemId", + }, + { + "in": "query", + "name": "limit", + "schema": { + "$ref": "#/components/schemas/Limit", + }, + }, + ], + requestBody={ + "$ref": "#/components/requestBodies/UpdateBody", + }, + ), + }, + }, + components={ + "parameters": { + "ItemId": { + "in": "path", + "name": "item_id", + "required": True, + "schema": { + "type": "string", + }, + }, + }, + "requestBodies": { + "UpdateBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item", + }, + }, + }, + "required": True, + }, + }, + "schemas": { + "Item": { + "properties": { + "name": { + "type": "string", + }, + }, + "required": ["name"], + "type": "object", + }, + "Limit": { + "maximum": 100, + "type": "integer", + }, + }, + }, + ) + client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={}))) + toolset = OpenAPIToolSet(document, client=client) + tool = toolset.tools[0] + + assert tool.name == "get_item" + assert tool.input_schema == { + "additionalProperties": False, + "properties": { + "item_id": { + "type": "string", + }, + "limit": { + "maximum": 100, + "type": "integer", + }, + "request_body": { + "properties": { + "name": { + "type": "string", + }, + }, + "required": ["name"], + "type": "object", + }, + }, + "required": ["item_id", "request_body"], + "type": "object", + } + declaration = tool._get_declaration() + assert declaration.parameters_json_schema == tool.input_schema + await client.aclose() + + +@pytest.mark.asyncio +async def test_executes_all_parameter_locations_json_body_headers_auth_and_override(): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(201, headers={"content-type": "application/json"}, json={"saved": True}) + + parameters = [ + { + "in": "path", + "name": "item_id", + "required": True, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "tag", + "schema": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + { + "in": "header", + "name": "X-Request-ID", + "required": True, + "schema": { + "type": "string", + }, + }, + { + "in": "cookie", + "name": "session", + "schema": { + "type": "string", + }, + }, + ] + document = _document({ + "/items/{item_id}": { + "post": + _operation( + operation_id="save_item", + parameters=parameters, + requestBody={ + "content": { + "application/json": { + "schema": { + "type": "object", + }, + }, + }, + "required": True, + }, + ), + }, + }) + toolset = OpenAPIToolSet( + document, + allowed_hosts=["mock.local"], + auth=httpx.BasicAuth("demo", "secret"), + base_url="https://mock.local/api", + static_headers={ + "X-API-Key": "fixed", + "X-Request-ID": "trusted", + }, + timeout=4.5, + transport=httpx.MockTransport(handler), + ) + + result = await _run( + toolset.tools[0], + { + "X-Request-ID": "model-value", + "item_id": "a/b", + "request_body": { + "name": "desk", + }, + "session": "cookie-value", + "tag": ["red", "blue"], + }, + ) + + assert result == { + "content": { + "saved": True, + }, + "content_type": "application/json", + "status_code": 201, + } + request = captured[0] + assert request.method == "POST" + assert request.url.raw_path.split(b"?", 1)[0] == b"/api/items/a%2Fb" + assert request.url.params.get_list("tag") == ["red", "blue"] + assert request.headers["authorization"].startswith("Basic ") + assert request.headers["x-api-key"] == "fixed" + assert request.headers["x-request-id"] == "trusted" + assert request.headers["cookie"] == "session=cookie-value" + assert json.loads(request.content) == { + "name": "desk", + } + assert request.extensions["timeout"]["read"] == 4.5 + await toolset.close() + + +@pytest.mark.asyncio +async def test_operation_filtering_and_standard_tool_filter(): + document = _document({ + "/first": { + "get": _operation("first"), + }, + "/second": { + "get": _operation("second"), + }, + }) + client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={}))) + + toolset = OpenAPIToolSet(document, client=client, operation_ids=["second"]) + assert [tool.name for tool in toolset.tools] == ["second"] + + filtered = OpenAPIToolSet( + document, + client=client, + is_include_all_tools=False, + tool_filter=["first"], + ) + assert [tool.name for tool in await filtered.get_tools()] == ["first"] + await client.aclose() + + +@pytest.mark.asyncio +async def test_non_json_response_and_empty_response_are_parsed(): + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/text"): + return httpx.Response(200, headers={"content-type": "text/plain; charset=utf-8"}, text="hello") + return httpx.Response(204) + + document = _document({ + "/empty": { + "delete": _operation("delete_item"), + }, + "/text": { + "get": _operation("get_text"), + }, + }) + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + toolset = OpenAPIToolSet(document, client=client) + + text_result = await _run(next(tool for tool in toolset.tools if tool.name == "get_text"), {}) + empty_result = await _run(next(tool for tool in toolset.tools if tool.name == "delete_item"), {}) + + assert text_result["content"] == "hello" + assert text_result["content_type"].startswith("text/plain") + assert empty_result["content"] is None + assert empty_result["status_code"] == 204 + await client.aclose() + + +@pytest.mark.asyncio +async def test_http_and_transport_errors_are_clear(): + document = _document({ + "/items": { + "get": _operation(), + }, + }) + error_client = httpx.AsyncClient(transport=httpx.MockTransport( + lambda request: httpx.Response(404, headers={"content-type": "application/json"}, json={"error": "gone"}))) + toolset = OpenAPIToolSet(document, client=error_client) + + with pytest.raises(OpenAPIHTTPError, match="HTTP 404") as error_info: + await _run(toolset.tools[0], {}) + assert error_info.value.content == { + "error": "gone", + } + assert error_info.value.status_code == 404 + await error_client.aclose() + + def fail(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("offline", request=request) + + transport_client = httpx.AsyncClient(transport=httpx.MockTransport(fail)) + transport_toolset = OpenAPIToolSet(document, client=transport_client) + with pytest.raises(OpenAPIToolError, match="request failed: offline"): + await _run(transport_toolset.tools[0], {}) + await transport_client.aclose() + + +@pytest.mark.asyncio +async def test_required_arguments_and_host_allowlist_are_enforced(): + document = _document({ + "/items/{item_id}": { + "get": + _operation(parameters=[ + { + "in": "path", + "name": "item_id", + "required": True, + "schema": { + "type": "string", + }, + }, + ], ), + }, + }) + client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(200))) + toolset = OpenAPIToolSet(document, allowed_hosts=["other.example"], client=client) + + with pytest.raises(OpenAPIToolError, match="Missing required argument 'item_id'"): + await _run(toolset.tools[0], {}) + with pytest.raises(OpenAPIToolError, match="Host 'api.example.test' is not allowed"): + await _run(toolset.tools[0], { + "item_id": "1", + }) + await client.aclose() + + +@pytest.mark.asyncio +async def test_allowed_hosts_are_case_insensitive_and_normalized(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200) + + toolset = OpenAPIToolSet( + _document({ + "/items": { + "get": _operation(), + }, + }), + allowed_hosts=["API.EXAMPLE.TEST."], + transport=httpx.MockTransport(handler), + ) + + await _run(toolset.tools[0], {}) + + assert len(requests) == 1 + await toolset.close() + + +@pytest.mark.parametrize( + "allowed_hosts", + [ + "api.example.test", + ["https://api.example.test"], + ["api.example.test:443"], + ["bad_host.example"], + [" api.example.test"], + ], +) +def test_rejects_invalid_allowed_hosts(allowed_hosts: Any): + with pytest.raises(OpenAPISpecError, match="allowed host|allowed_hosts"): + OpenAPIToolSet(_document({}), allowed_hosts=allowed_hosts) + + +@pytest.mark.parametrize( + "path", + [ + "https://evil.example/items", + "items", + "//evil.example/items", + "/items?redirect=https://evil.example", + "/items#fragment", + ], +) +def test_rejects_invalid_path_keys_before_any_request(path: str): + request_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal request_count + request_count += 1 + return httpx.Response(200) + + with pytest.raises(OpenAPISpecError, match="path template"): + OpenAPIToolSet( + _document({ + path: { + "get": _operation(), + }, + }), + transport=httpx.MockTransport(handler), + ) + + assert request_count == 0 + + +@pytest.mark.parametrize( + ("paths", "message"), + [ + ( + { + "/missing": { + "get": { + "responses": {}, + }, + }, + }, + "missing operationId", + ), + ( + { + "/unsafe": { + "get": _operation("../unsafe"), + }, + }, + "Unsafe OpenAPI operationId", + ), + ( + { + "/first": { + "get": _operation("duplicate"), + }, + "/second": { + "post": _operation("duplicate"), + }, + }, + "Duplicate OpenAPI operationId", + ), + ], +) +def test_rejects_missing_unsafe_and_duplicate_operation_ids(paths: dict[str, Any], message: str): + with pytest.raises(OpenAPISpecError, match=message): + OpenAPIToolSet(_document(paths)) + + +def test_rejects_unknown_operation_filter_and_parameter_name_collision(): + document = _document({ + "/items": { + "get": + _operation(parameters=[ + { + "in": "query", + "name": "token", + "schema": { + "type": "string", + }, + }, + { + "in": "header", + "name": "token", + "schema": { + "type": "string", + }, + }, + ], ), + }, + }) + with pytest.raises(OpenAPISpecError, match="Unknown OpenAPI operationId"): + OpenAPIToolSet(document, operation_ids=["absent"]) + with pytest.raises(OpenAPISpecError, match="both query and header"): + OpenAPIToolSet(document) + + +def test_loads_mapping_json_and_yaml_sources(tmp_path: Path): + document = _document({}) + json_path = tmp_path / "openapi.json" + yaml_path = tmp_path / "openapi.yaml" + json_path.write_text(json.dumps(document), encoding="utf-8") + yaml_path.write_text(yaml.safe_dump(document), encoding="utf-8") + + assert load_openapi_document(document) == document + assert load_openapi_document(json_path) == document + assert load_openapi_document(yaml_path) == document + + +@pytest.mark.parametrize( + ("source", "message"), + [ + ("https://example.com/openapi.yaml", "not a local file"), + ({ + "openapi": "2.0", + "paths": {} + }, "Only OpenAPI 3.x"), + ({ + "openapi": "3.1.0" + }, "must contain a 'paths' object"), + ], +) +def test_rejects_remote_or_invalid_sources(source: Any, message: str): + with pytest.raises(OpenAPISpecError, match=message): + load_openapi_document(source) + + +def test_rejects_remote_and_circular_refs(): + remote = _document({ + "/items": { + "get": _operation(parameters=[{ + "$ref": "https://example.com/parameters.yaml#/Item", + }]), + }, + }) + with pytest.raises(OpenAPISpecError, match="Only local OpenAPI references"): + OpenAPIToolSet(remote) + + circular = _document( + { + "/items": { + "get": _operation(parameters=[{ + "$ref": "#/components/parameters/Loop", + }]), + }, + }, + components={ + "parameters": { + "Loop": { + "$ref": "#/components/parameters/Loop", + }, + }, + }, + ) + with pytest.raises(OpenAPISpecError, match="Circular OpenAPI reference"): + OpenAPIToolSet(circular) diff --git a/trpc_agent_sdk/models/_openai_model.py b/trpc_agent_sdk/models/_openai_model.py index d95d77c11..cbeaf6923 100644 --- a/trpc_agent_sdk/models/_openai_model.py +++ b/trpc_agent_sdk/models/_openai_model.py @@ -12,6 +12,7 @@ import asyncio import base64 +import copy import functools import inspect import json @@ -1867,8 +1868,12 @@ def _convert_tools_to_openai_format(self, tools: List[Tool]) -> List[Dict[str, A }, } - # Convert parameters schema - always include parameters field - if func_decl.parameters: + # Prefer raw JSON Schema when provided. OpenAPI and MCP + # tools use parameters_json_schema to preserve nested + # objects, required fields, and other JSON Schema details. + if func_decl.parameters_json_schema: + openai_tool["function"]["parameters"] = copy.deepcopy(func_decl.parameters_json_schema) + elif func_decl.parameters: openai_tool["function"]["parameters"] = self._convert_schema_to_openai_format( func_decl.parameters) else: diff --git a/trpc_agent_sdk/tools/__init__.py b/trpc_agent_sdk/tools/__init__.py index ff967b69d..11aff777a 100644 --- a/trpc_agent_sdk/tools/__init__.py +++ b/trpc_agent_sdk/tools/__init__.py @@ -97,6 +97,12 @@ from .mcp_tool import StdioConnectionParams from .mcp_tool import StreamableHTTPConnectionParams from .mcp_tool import patch_mcp_cancel_scope_exit_issue +from ._openapi_tools import OpenAPIHTTPError +from ._openapi_tools import OpenAPISpecError +from ._openapi_tools import OpenAPITool +from ._openapi_tools import OpenAPIToolError +from ._openapi_tools import OpenAPIToolSet +from ._openapi_tools import load_openapi_document from .utils import build_function_declaration from .utils import from_function_with_options from .utils import get_required_fields @@ -188,6 +194,12 @@ "StdioConnectionParams", "StreamableHTTPConnectionParams", "patch_mcp_cancel_scope_exit_issue", + "OpenAPIHTTPError", + "OpenAPISpecError", + "OpenAPITool", + "OpenAPIToolError", + "OpenAPIToolSet", + "load_openapi_document", "build_function_declaration", "from_function_with_options", "get_required_fields", diff --git a/trpc_agent_sdk/tools/_openapi_tools.py b/trpc_agent_sdk/tools/_openapi_tools.py new file mode 100644 index 000000000..ca4745eca --- /dev/null +++ b/trpc_agent_sdk/tools/_openapi_tools.py @@ -0,0 +1,559 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Convert OpenAPI 3.x operations into executable agent tools.""" + +from __future__ import annotations + +import copy +import ipaddress +import json +import re +from collections.abc import Iterable +from collections.abc import Mapping +from http.cookies import SimpleCookie +from pathlib import Path +from typing import Any +from typing import Optional +from typing import Union +from urllib.parse import quote +from urllib.parse import urljoin +from urllib.parse import urlparse + +import httpx +import yaml +from typing_extensions import override + +from trpc_agent_sdk.abc import ToolPredicate +from trpc_agent_sdk.abc import ToolSetABC as BaseToolSet +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.types import FunctionDeclaration + +from ._base_tool import BaseTool + +_HTTP_METHODS = ("delete", "get", "head", "options", "patch", "post", "put", "trace") +_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]{0,63}$") +_PARAMETER_LOCATIONS = {"cookie", "header", "path", "query"} + + +class OpenAPIToolError(RuntimeError): + """Base exception for OpenAPI tool conversion and execution.""" + + +class OpenAPISpecError(OpenAPIToolError, ValueError): + """Raised when an OpenAPI document cannot be converted safely.""" + + +class OpenAPIHTTPError(OpenAPIToolError): + """Raised when an OpenAPI operation returns an HTTP error.""" + + 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}") + + +class _LocalRefResolver: + """Resolve local JSON Pointer references without mutating the document.""" + + def __init__(self, document: Mapping[str, Any]): + self._document = document + + def resolve(self, value: Any, stack: tuple[str, ...] = ()) -> Any: + if isinstance(value, list): + return [self.resolve(item, stack) for item in value] + if not isinstance(value, Mapping): + return copy.deepcopy(value) + + reference = value.get("$ref") + if reference is not None: + if not isinstance(reference, str) or not reference.startswith("#/"): + raise OpenAPISpecError(f"Only local OpenAPI references are supported: {reference!r}") + if reference in stack: + chain = " -> ".join((*stack, reference)) + raise OpenAPISpecError(f"Circular OpenAPI reference is not supported: {chain}") + target = self._lookup(reference) + resolved = self.resolve(target, (*stack, reference)) + if not isinstance(resolved, dict): + raise OpenAPISpecError(f"OpenAPI reference must resolve to an object: {reference}") + siblings = {key: item for key, item in value.items() if key != "$ref"} + resolved.update(self.resolve(siblings, stack)) + return resolved + + return {str(key): self.resolve(item, stack) for key, item in value.items()} + + def _lookup(self, reference: str) -> Any: + current: Any = self._document + for encoded_part in reference[2:].split("/"): + part = encoded_part.replace("~1", "/").replace("~0", "~") + if not isinstance(current, Mapping) or part not in current: + raise OpenAPISpecError(f"Unresolvable OpenAPI reference: {reference}") + current = current[part] + return current + + +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 + + +def load_openapi_document(source: Union[Mapping[str, Any], str, Path]) -> dict[str, Any]: + """Load and validate an OpenAPI 3.x document from a mapping or local file.""" + if isinstance(source, Mapping): + document = copy.deepcopy(dict(source)) + else: + path = Path(source).expanduser() + if not path.is_file(): + raise OpenAPISpecError(f"OpenAPI document is not a local file: {path}") + try: + text = path.read_text(encoding="utf-8") + except OSError as error: + raise OpenAPISpecError(f"Unable to read OpenAPI document {path}: {error}") from error + document = _parse_document_text(text, path) + + if not isinstance(document, dict): + raise OpenAPISpecError("OpenAPI document must be a JSON/YAML object") + version = document.get("openapi") + if not isinstance(version, str) or not version.startswith("3."): + raise OpenAPISpecError("Only OpenAPI 3.x documents are supported") + paths = document.get("paths") + if not isinstance(paths, Mapping): + raise OpenAPISpecError("OpenAPI document must contain a 'paths' object") + for path in paths: + _validate_path_template(path) + return document + + +def _parse_document_text(text: str, path: Path) -> dict[str, Any]: + try: + if path.suffix.lower() == ".json": + value = json.loads(text) + else: + value = yaml.safe_load(text) + except (json.JSONDecodeError, yaml.YAMLError) as error: + raise OpenAPISpecError(f"Invalid OpenAPI document {path}: {error}") from error + if not isinstance(value, dict): + raise OpenAPISpecError(f"OpenAPI document {path} must contain an object") + return value + + +def _server_url(server: Any) -> Optional[str]: + if not isinstance(server, Mapping): + return None + value = server.get("url") + if not isinstance(value, str) or not value: + return None + variables = server.get("variables", {}) + if not isinstance(variables, Mapping): + raise OpenAPISpecError("OpenAPI server variables must be an object") + for name, config in variables.items(): + if not isinstance(config, Mapping) or "default" not in config: + raise OpenAPISpecError(f"OpenAPI server variable '{name}' requires a default") + value = value.replace("{" + str(name) + "}", str(config["default"])) + if "{" in value or "}" in value: + raise OpenAPISpecError(f"Unresolved OpenAPI server variable in URL: {value}") + return value + + +def _select_server_url(*server_groups: Any) -> Optional[str]: + for servers in server_groups: + if isinstance(servers, list) and servers: + value = _server_url(servers[0]) + if value: + return value + return None + + +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("/") + + +def _normalize_hostname(value: Any) -> str: + if not isinstance(value, str) or not value or value != value.strip(): + raise OpenAPISpecError(f"OpenAPI allowed host must be a non-empty hostname string: {value!r}") + if value.endswith("."): + value = value[:-1] + if not value: + raise OpenAPISpecError("OpenAPI allowed host must not be empty") + + try: + return ipaddress.ip_address(value).compressed.lower() + except ValueError: + pass + + if ":" in value: + raise OpenAPISpecError(f"OpenAPI allowed host must not include a port: {value!r}") + try: + hostname = value.encode("idna").decode("ascii").lower() + except UnicodeError as error: + raise OpenAPISpecError(f"Invalid OpenAPI allowed host: {value!r}") from error + if len(hostname) > 253: + raise OpenAPISpecError(f"Invalid OpenAPI allowed host: {value!r}") + labels = hostname.split(".") + if any(not label or len(label) > 63 or not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", label) + for label in labels): + raise OpenAPISpecError(f"Invalid OpenAPI allowed host: {value!r}") + if len(labels) > 1 and all(label.isdigit() for label in labels): + raise OpenAPISpecError(f"Invalid OpenAPI allowed host: {value!r}") + return hostname + + +def _normalize_allowed_hosts(allowed_hosts: Optional[Iterable[str]]) -> Optional[frozenset[str]]: + if allowed_hosts is None: + return None + if isinstance(allowed_hosts, str): + raise OpenAPISpecError("OpenAPI allowed_hosts must be an iterable of hostname strings") + return frozenset(_normalize_hostname(host) for host in allowed_hosts) + + +def _normalize_schema(schema: Any) -> dict[str, Any]: + if not isinstance(schema, Mapping): + return {} + normalized = copy.deepcopy(dict(schema)) + nullable = normalized.pop("nullable", False) + normalized.pop("discriminator", None) + normalized.pop("example", None) + normalized.pop("externalDocs", None) + normalized.pop("readOnly", None) + normalized.pop("writeOnly", None) + normalized.pop("xml", None) + if nullable: + schema_type = normalized.get("type") + if isinstance(schema_type, str): + normalized["type"] = [schema_type, "null"] + for key in ("allOf", "anyOf", "oneOf"): + if isinstance(normalized.get(key), list): + normalized[key] = [_normalize_schema(item) for item in normalized[key]] + if isinstance(normalized.get("items"), Mapping): + normalized["items"] = _normalize_schema(normalized["items"]) + if isinstance(normalized.get("additionalProperties"), Mapping): + normalized["additionalProperties"] = _normalize_schema(normalized["additionalProperties"]) + properties = normalized.get("properties") + if isinstance(properties, Mapping): + normalized["properties"] = {str(name): _normalize_schema(item) for name, item in properties.items()} + return normalized + + +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 + + +class OpenAPITool(BaseTool): + """A single executable HTTP operation generated from OpenAPI.""" + + def __init__( + self, + *, + name: str, + description: str, + method: str, + path: str, + base_url: str, + input_schema: dict[str, Any], + parameters: list[dict[str, Any]], + request_body_required: bool, + client: httpx.AsyncClient, + static_headers: Mapping[str, str], + allowed_hosts: Optional[frozenset[str]], + filters_name: Optional[list[str]] = None, + filters: Optional[list[BaseFilter]] = None, + ): + super().__init__(name=name, description=description, filters_name=filters_name, filters=filters) + self._allowed_hosts = allowed_hosts + self._base_url = base_url + self._client = client + self._input_schema = input_schema + self._method = method + self._parameters = parameters + self._path = path + self._request_body_required = request_body_required + self._static_headers = dict(static_headers) + + @property + def input_schema(self) -> dict[str, Any]: + """Return a defensive copy of the generated JSON input schema.""" + return copy.deepcopy(self._input_schema) + + @override + def _get_declaration(self) -> FunctionDeclaration: + return FunctionDeclaration( + description=self.description, + name=self.name, + parameters_json_schema=self.input_schema, + ) + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + del tool_context + return await self.call(args) + + async def call(self, args: Mapping[str, Any]) -> dict[str, Any]: + """Execute the HTTP operation directly, without an agent context.""" + path = self._path + query: list[tuple[str, Any]] = [] + headers: dict[str, str] = {} + cookies = SimpleCookie() + + for parameter in self._parameters: + name = parameter["name"] + location = parameter["in"] + if name not in args: + if parameter.get("required"): + raise OpenAPIToolError(f"Missing required argument '{name}' for operation '{self.name}'") + continue + value = args[name] + if location == "path": + path = path.replace("{" + name + "}", quote(str(value), safe="")) + elif location == "query": + if isinstance(value, list): + query.extend((name, item) for item in value) + else: + query.append((name, value)) + elif location == "header": + headers[name] = str(value) + elif location == "cookie": + cookies[name] = str(value) + + unresolved = re.findall(r"\{[^{}]+\}", path) + if unresolved: + raise OpenAPIToolError(f"Unresolved path parameters for operation '{self.name}': {unresolved}") + + if cookies: + headers["Cookie"] = cookies.output(header="", sep=";").strip() + headers.update(self._static_headers) + request_kwargs: dict[str, Any] = { + "headers": headers, + "params": query, + } + if "request_body" in args: + request_kwargs["json"] = args["request_body"] + elif self._request_body_required: + raise OpenAPIToolError(f"Missing required argument 'request_body' for operation '{self.name}'") + + url = urljoin(self._base_url + "/", path.lstrip("/")) + raw_host = urlparse(url).hostname + host = _normalize_hostname(raw_host) + if self._allowed_hosts is not None and host not in self._allowed_hosts: + raise OpenAPIToolError(f"Host '{host}' is not allowed for operation '{self.name}'") + + 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, + } + + +class OpenAPIToolSet(BaseToolSet): + """A first-class toolset generated from an OpenAPI 3.x document.""" + + def __init__( + self, + source: Union[Mapping[str, Any], str, Path], + *, + base_url: Optional[str] = None, + operation_ids: Optional[Iterable[str]] = None, + static_headers: Optional[Mapping[str, str]] = None, + auth: Optional[httpx.Auth] = None, + timeout: Union[float, httpx.Timeout] = 30.0, + client: Optional[httpx.AsyncClient] = None, + transport: Optional[httpx.AsyncBaseTransport] = None, + allowed_hosts: Optional[Iterable[str]] = None, + tool_filter: Optional[Union[ToolPredicate, list[str]]] = None, + is_include_all_tools: bool = True, + name: str = "openapi", + ): + super().__init__(tool_filter=tool_filter, is_include_all_tools=is_include_all_tools, name=name) + 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) + self._static_headers = dict(static_headers or {}) + self._base_url_override = _validate_base_url(base_url) if base_url else None + self._tools = self._build_tools(operation_ids) + + @property + def tools(self) -> tuple[OpenAPITool, ...]: + """Return all converted operations before context filtering.""" + return tuple(self._tools) + + @override + async def get_tools(self, invocation_context: Optional[InvocationContext] = None) -> list[OpenAPITool]: + return [tool for tool in self._tools if self._is_tool_selected(tool, invocation_context)] + + @override + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + def _build_tools(self, operation_ids: Optional[Iterable[str]]) -> list[OpenAPITool]: + selected = set(operation_ids) if operation_ids is not None else None + resolver = _LocalRefResolver(self.document) + operations: list[tuple[str, str, str, Mapping[str, Any], Mapping[str, Any]]] = [] + seen: set[str] = set() + + for path, raw_path_item in self.document["paths"].items(): + if not isinstance(raw_path_item, Mapping): + raise OpenAPISpecError("OpenAPI paths must map path strings to objects") + path_item = (resolver.resolve(raw_path_item) if "$ref" in raw_path_item else dict(raw_path_item)) + for method in _HTTP_METHODS: + raw_operation = path_item.get(method) + if raw_operation is None: + continue + if not isinstance(raw_operation, Mapping): + raise OpenAPISpecError(f"OpenAPI operation {method.upper()} {path} must be an object") + operation = (resolver.resolve(raw_operation) if "$ref" in raw_operation else dict(raw_operation)) + operation_id = operation.get("operationId") + self._validate_operation_id(operation_id, method, path, seen) + seen.add(operation_id) + operations.append((operation_id, method, path, path_item, operation)) + + if selected is not None: + missing = selected - seen + if missing: + raise OpenAPISpecError(f"Unknown OpenAPI operationId values: {sorted(missing)}") + + tools = [] + for operation_id, method, path, path_item, operation in operations: + if selected is not None and operation_id not in selected: + continue + tools.append(self._build_tool(resolver, operation_id, method, path, path_item, operation)) + return tools + + @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: + raise OpenAPISpecError(f"Duplicate OpenAPI operationId: {operation_id!r}") + + def _build_tool( + self, + resolver: _LocalRefResolver, + operation_id: str, + method: str, + path: str, + path_item: Mapping[str, Any], + operation: Mapping[str, Any], + ) -> OpenAPITool: + parameters = self._collect_parameters(resolver, path_item, operation, operation_id) + properties = {parameter["name"]: _normalize_schema(parameter.get("schema", {})) for parameter in parameters} + required = [parameter["name"] for parameter in parameters if parameter.get("required")] + body_required = False + raw_body = operation.get("requestBody") + if raw_body is not None: + body = resolver.resolve(raw_body) + content = body.get("content", {}) if isinstance(body, Mapping) else {} + media_type = content.get("application/json") if isinstance(content, Mapping) else None + if not isinstance(media_type, Mapping) or not isinstance(media_type.get("schema"), Mapping): + raise OpenAPISpecError(f"Operation '{operation_id}' requestBody must define an application/json schema") + if "request_body" in properties: + raise OpenAPISpecError( + f"Operation '{operation_id}' parameter name 'request_body' conflicts with requestBody") + properties["request_body"] = _normalize_schema(resolver.resolve(media_type["schema"])) + body_required = bool(body.get("required")) + if body_required: + required.append("request_body") + + input_schema: dict[str, Any] = { + "additionalProperties": False, + "properties": properties, + "type": "object", + } + if required: + input_schema["required"] = required + + raw_base_url = self._base_url_override or _select_server_url( + operation.get("servers"), + path_item.get("servers"), + self.document.get("servers"), + ) + if not raw_base_url: + raise OpenAPISpecError( + f"Operation '{operation_id}' has no server URL; provide base_url or an OpenAPI servers entry") + base_url = _validate_base_url(raw_base_url) + description = operation.get("description") or operation.get("summary") or f"{method.upper()} {path}" + return OpenAPITool( + allowed_hosts=self._allowed_hosts, + base_url=base_url, + client=self._client, + description=str(description), + input_schema=input_schema, + method=method.upper(), + name=operation_id, + parameters=parameters, + path=path, + request_body_required=body_required, + static_headers=self._static_headers, + ) + + @staticmethod + def _collect_parameters( + resolver: _LocalRefResolver, + path_item: Mapping[str, Any], + operation: Mapping[str, Any], + operation_id: str, + ) -> list[dict[str, Any]]: + combined: dict[tuple[str, str], dict[str, Any]] = {} + for raw_parameters in (path_item.get("parameters", []), operation.get("parameters", [])): + if not isinstance(raw_parameters, list): + raise OpenAPISpecError(f"Operation '{operation_id}' parameters must be a list") + for raw_parameter in raw_parameters: + parameter = resolver.resolve(raw_parameter) + if not isinstance(parameter, dict): + raise OpenAPISpecError(f"Operation '{operation_id}' parameter must be an object") + name = parameter.get("name") + location = parameter.get("in") + if not isinstance(name, str) or not name: + raise OpenAPISpecError(f"Operation '{operation_id}' has a parameter without a name") + if location not in _PARAMETER_LOCATIONS: + raise OpenAPISpecError( + f"Operation '{operation_id}' parameter '{name}' has unsupported location {location!r}") + if not isinstance(parameter.get("schema"), Mapping): + raise OpenAPISpecError(f"Operation '{operation_id}' parameter '{name}' must define a schema") + if location == "path" and not parameter.get("required"): + raise OpenAPISpecError(f"Operation '{operation_id}' path parameter '{name}' must be required") + combined[(name, location)] = parameter + + by_name: dict[str, str] = {} + for name, location in combined: + existing = by_name.setdefault(name, location) + if existing != location: + raise OpenAPISpecError( + f"Operation '{operation_id}' uses parameter '{name}' in both {existing} and {location}") + return list(combined.values())