From d8c8033ea954e0efac6b24716a2c57203e6eb547 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 8 Jul 2026 09:22:16 -0500 Subject: [PATCH 01/19] Add action gateway SDK functions --- Makefile | 5 +- examples/gateway/async_invoke_tools.py | 33 ++ examples/gateway/execute_code.py | 26 ++ examples/gateway/function_calling_loop.py | 51 +++ examples/gateway/invoke_tools.py | 45 +++ examples/gateway/list_tools.py | 26 ++ examples/gateway/messages_tool_use.py | 51 +++ examples/gateway/search_tools.py | 27 ++ src/pydo/_patch.py | 21 ++ src/pydo/aio/_patch.py | 21 ++ src/pydo/aio/gateway/__init__.py | 82 +++++ src/pydo/aio/gateway/custom_operations.py | 271 +++++++++++++++ src/pydo/gateway/__init__.py | 137 ++++++++ src/pydo/gateway/custom_models.py | 140 ++++++++ src/pydo/gateway/custom_operations.py | 287 ++++++++++++++++ src/pydo/gateway/providers.py | 300 +++++++++++++++++ src/pydo/gateway/transport.py | 232 +++++++++++++ tests/gateway/__init__.py | 0 tests/gateway/conftest.py | 131 ++++++++ tests/gateway/test_async_gateway.py | 141 ++++++++ tests/gateway/test_code.py | 68 ++++ tests/gateway/test_providers.py | 380 ++++++++++++++++++++++ tests/gateway/test_tools.py | 207 ++++++++++++ tests/gateway/test_transport.py | 190 +++++++++++ 24 files changed, 2871 insertions(+), 1 deletion(-) create mode 100644 examples/gateway/async_invoke_tools.py create mode 100644 examples/gateway/execute_code.py create mode 100644 examples/gateway/function_calling_loop.py create mode 100644 examples/gateway/invoke_tools.py create mode 100644 examples/gateway/list_tools.py create mode 100644 examples/gateway/messages_tool_use.py create mode 100644 examples/gateway/search_tools.py create mode 100644 src/pydo/aio/gateway/__init__.py create mode 100644 src/pydo/aio/gateway/custom_operations.py create mode 100644 src/pydo/gateway/__init__.py create mode 100644 src/pydo/gateway/custom_models.py create mode 100644 src/pydo/gateway/custom_operations.py create mode 100644 src/pydo/gateway/providers.py create mode 100644 src/pydo/gateway/transport.py create mode 100644 tests/gateway/__init__.py create mode 100644 tests/gateway/conftest.py create mode 100644 tests/gateway/test_async_gateway.py create mode 100644 tests/gateway/test_code.py create mode 100644 tests/gateway/test_providers.py create mode 100644 tests/gateway/test_tools.py create mode 100644 tests/gateway/test_transport.py diff --git a/Makefile b/Makefile index c1de38b6..73aaf2d3 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,10 @@ clean: ## Removes all generated code (except _patch.py files) @printf "=== Cleaning src directory\n" @rm -rf src/pydo/resources @rm -rf src/pydo/types - @find src/pydo -type f ! -name "_patch.py" ! -name "custom_*.py" ! -name "exceptions.py" -exec rm -rf {} + + @find src/pydo -type f \ + ! -name "_patch.py" ! -name "custom_*.py" ! -name "exceptions.py" \ + ! -path "*/gateway/*" \ + -exec rm -rf {} + .PHONY: download-spec download-spec: ## Download Latest DO Spec diff --git a/examples/gateway/async_invoke_tools.py b/examples/gateway/async_invoke_tools.py new file mode 100644 index 00000000..90931485 --- /dev/null +++ b/examples/gateway/async_invoke_tools.py @@ -0,0 +1,33 @@ +"""Async Action Gateway usage: list, invoke, and execute code. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run +""" + +import asyncio +import os + +from pydo.aio import Client + + +async def main() -> None: + client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + + tools = await client.gateway.tools.list(include_all=True) + print("catalog:", [tool.name for tool in tools]) + + output = await client.gateway.tools.invoke_one( + "web_search", {"query": "DigitalOcean Gradient", "max_results": 2} + ) + print("web_search output:", str(output)[:200]) + + result = await client.gateway.code.execute("print('hello from async')") + print("code stdout:", result.get("stdout")) + + await client.close() + + +asyncio.run(main()) diff --git a/examples/gateway/execute_code.py b/examples/gateway/execute_code.py new file mode 100644 index 00000000..0d4ca802 --- /dev/null +++ b/examples/gateway/execute_code.py @@ -0,0 +1,26 @@ +"""Run Python code in the Action Gateway sandbox (action.code). + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run +""" + +import os + +from pydo import Client + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + +result = client.gateway.code.execute( + "import sys\n" "print('hello from the sandbox')\n" "print(sys.version)\n", + thought="verify the sandbox works", +) + +print("exit_code:", result.get("exit_code")) +print("stdout:") +print(result.get("stdout")) +if result.get("stderr"): + print("stderr:") + print(result.get("stderr")) diff --git a/examples/gateway/function_calling_loop.py b/examples/gateway/function_calling_loop.py new file mode 100644 index 00000000..5c92cded --- /dev/null +++ b/examples/gateway/function_calling_loop.py @@ -0,0 +1,51 @@ +"""Agentic function-calling loop: chat completions + Action Gateway. + +The model is handed the gateway's meta-tools (action.search / +action.invoke / action.code) so it can discover and execute tools +itself. handle_tool_calls() runs whatever the model asked for and +returns ready-to-append tool messages; the loop continues until the +model answers with plain text. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + MODEL + PROMPT +""" + +import os + +from pydo import Client + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + +model = os.environ.get("MODEL", "llama3.3-70b-instruct") +prompt = os.environ.get( + "PROMPT", "Find the latest news about DigitalOcean and summarize it." +) + +# Meta-tools by default; use tools(include_all=True) for the concrete catalog. +tools = client.gateway.tools() + +messages = [{"role": "user", "content": prompt}] + +while True: + response = client.chat.completions.create( + model=model, + messages=messages, + tools=tools, + ) + message = response.choices[0].message + if not message.get("tool_calls"): + break + + messages.append(dict(message)) + tool_messages = client.gateway.handle_tool_calls(response) + for tool_message in tool_messages: + print(f"[tool result] {str(tool_message['content'])[:120]}") + messages.extend(tool_messages) + +print("\nFinal answer:\n") +print(message.get("content")) diff --git a/examples/gateway/invoke_tools.py b/examples/gateway/invoke_tools.py new file mode 100644 index 00000000..2e61862a --- /dev/null +++ b/examples/gateway/invoke_tools.py @@ -0,0 +1,45 @@ +"""Invoke Action Gateway tools in parallel (action.invoke). + +Per-tool failures are reported inside the response envelope rather than +raising, so a mixed batch always returns all results. Use +tools.invoke_one() when you want a single output or an exception. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run +""" + +import os + +from pydo import Client + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + +envelope = client.gateway.tools.invoke( + [ + { + "tool": "web_search", + "arguments": {"query": "DigitalOcean Gradient", "max_results": 3}, + }, + {"tool": "web_fetch", "arguments": {"url": "https://www.digitalocean.com"}}, + ], + rationale="demonstrate parallel tool invocation", +) + +print(f"{envelope.success_count}/{envelope.total_count} succeeded\n") +for item in envelope.results: + result = item.result + print(f"[{item.index}] {item.tool}: {result.status}") + if result.status == "succeeded": + print(f" output: {str(result.get('output'))[:200]}") + else: + error = result.get("error", {}) + print(f" error ({error.get('class')}): {error.get('message')}") + +# Single tool, direct output (raises GatewayToolError on failure): +output = client.gateway.tools.invoke_one( + "web_search", {"query": "MCP protocol", "max_results": 1} +) +print("\ninvoke_one output:", str(output)[:200]) diff --git a/examples/gateway/list_tools.py b/examples/gateway/list_tools.py new file mode 100644 index 00000000..9ba6ac6d --- /dev/null +++ b/examples/gateway/list_tools.py @@ -0,0 +1,26 @@ +"""List Action Gateway tools. + +By default the gateway exposes three meta-tools (action.search, +action.invoke, action.code) that let a model drive tool discovery and +execution itself. Pass include_all=True for the full concrete catalog. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run +""" + +import os + +from pydo import Client + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + +print("Meta-tools (default):") +for tool in client.gateway.tools.list(): + print(f" {tool.name}: {tool.get('description', '')[:80]}") + +print("\nFull concrete catalog:") +for tool in client.gateway.tools.list(include_all=True): + print(f" {tool.name}: {tool.get('description', '')[:80]}") diff --git a/examples/gateway/messages_tool_use.py b/examples/gateway/messages_tool_use.py new file mode 100644 index 00000000..5a4e2f3f --- /dev/null +++ b/examples/gateway/messages_tool_use.py @@ -0,0 +1,51 @@ +"""Tool use via the Messages API (Anthropic format) + Action Gateway. + +Identical loop to function_calling_loop.py — the only change is the +provider passed at construction, which switches the tools= format and +the tool-call parsing to the Messages API shapes. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + MODEL + PROMPT +""" + +import os + +from pydo import Client +from pydo.gateway import MessagesProvider + +client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + gateway_provider=MessagesProvider(), +) + +model = os.environ.get("MODEL", "anthropic-claude-3.5-haiku") +prompt = os.environ.get( + "PROMPT", "Find the latest news about DigitalOcean and summarize it." +) + +tools = client.gateway.tools() # Messages-format tool definitions + +messages = [{"role": "user", "content": prompt}] + +while True: + response = client.messages.create( + model=model, + max_tokens=1024, + tools=tools, + messages=messages, + ) + if response.get("stop_reason") != "tool_use": + break + + messages.append({"role": "assistant", "content": list(response.content)}) + # One user turn containing all tool_result blocks: + messages.extend(client.gateway.handle_tool_calls(response)) + +for block in response.content: + if block.get("type") == "text": + print(block.text) diff --git a/examples/gateway/search_tools.py b/examples/gateway/search_tools.py new file mode 100644 index 00000000..35c3c598 --- /dev/null +++ b/examples/gateway/search_tools.py @@ -0,0 +1,27 @@ +"""Search the Action Gateway tool catalog by use case (action.search). + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + USE_CASE +""" + +import os + +from pydo import Client + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + +use_case = os.environ.get("USE_CASE", "search the public web for a topic") + +payload = client.gateway.tools.search(use_case, limit=3) + +for group in payload.get("results", []): + print(f"use case: {group.get('use_case')}") + for match in group.get("results", []): + print(f" {match.get('name')} (score {match.get('score')})") + print(f" {match.get('description', '')[:100]}") + if group.get("guidance"): + print(f" guidance: {group['guidance']}") diff --git a/src/pydo/_patch.py b/src/pydo/_patch.py index d979f13d..5125202f 100644 --- a/src/pydo/_patch.py +++ b/src/pydo/_patch.py @@ -6,6 +6,7 @@ Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize """ + from typing import Optional from azure.core.credentials import AccessToken @@ -58,6 +59,13 @@ class Client( # type: ignore subdomain (e.g. ``"https://.agents.do-ai.run"``). Required only when using agent inference endpoints. :paramtype agent_endpoint: str + :keyword gateway_endpoint: Action Gateway base URL (default + ``https://actions.do-ai.run``; preview is + ``https://actions.do-ai-test.run``; override via + ``PYDO_GATEWAY_ENDPOINT``). + :keyword gateway_provider: Provider that formats gateway tools for an + inference surface (default :class:`ChatCompletionsProvider`; also + ``MessagesProvider`` and ``ResponsesProvider`` in ``pydo.gateway``). """ def __init__( @@ -68,6 +76,8 @@ def __init__( timeout: int = 120, inference_endpoint: str = INFERENCE_BASE_URL, agent_endpoint: str = "", + gateway_endpoint: Optional[str] = None, + gateway_provider=None, **kwargs, ): if token is not None and api_key is not None: @@ -111,6 +121,17 @@ def __init__( self.images.generate = inference_images.generate self.images.generations = inference_images.generations + try: + from pydo.gateway import GatewayResources + except ImportError: + self.gateway = None + else: + self.gateway = GatewayResources( + self, + gateway_endpoint=gateway_endpoint, + provider=gateway_provider, + ) + def _setup_inference_routing( self, inference_endpoint: str, diff --git a/src/pydo/aio/_patch.py b/src/pydo/aio/_patch.py index 1d317f97..4299faf0 100644 --- a/src/pydo/aio/_patch.py +++ b/src/pydo/aio/_patch.py @@ -6,6 +6,7 @@ Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize """ + from typing import TYPE_CHECKING, Optional from azure.core.credentials import AccessToken @@ -64,6 +65,13 @@ class Client( # type: ignore subdomain (e.g. ``"https://.agents.do-ai.run"``). Required only when using agent inference endpoints. :paramtype agent_endpoint: str + :keyword gateway_endpoint: Action Gateway base URL (default + ``https://actions.do-ai.run``; preview is + ``https://actions.do-ai-test.run``; override via + ``PYDO_GATEWAY_ENDPOINT``). + :keyword gateway_provider: Provider that formats gateway tools for an + inference surface (default :class:`ChatCompletionsProvider`; also + ``MessagesProvider`` and ``ResponsesProvider`` in ``pydo.gateway``). """ def __init__( @@ -74,6 +82,8 @@ def __init__( timeout: int = 120, inference_endpoint: str = INFERENCE_BASE_URL, agent_endpoint: str = "", + gateway_endpoint: Optional[str] = None, + gateway_provider=None, **kwargs, ): if token is not None and api_key is not None: @@ -117,6 +127,17 @@ def __init__( self.images.generate = inference_images.generate self.images.generations = inference_images.generations + try: + from pydo.aio.gateway import AsyncGatewayResources + except ImportError: + self.gateway = None + else: + self.gateway = AsyncGatewayResources( + self, + gateway_endpoint=gateway_endpoint, + provider=gateway_provider, + ) + def _setup_inference_routing( self, inference_endpoint: str, diff --git a/src/pydo/aio/gateway/__init__.py b/src/pydo/aio/gateway/__init__.py new file mode 100644 index 00000000..c940ec72 --- /dev/null +++ b/src/pydo/aio/gateway/__init__.py @@ -0,0 +1,82 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Async Action Gateway API — hand-written; preserved across ``make generate``.""" + +from __future__ import annotations + +from typing import Any, List, Optional, Sequence + +from pydo.custom_extensions import _BaseURLProxy +from pydo.gateway import resolve_gateway_base_url +from pydo.gateway.custom_models import ToolCall +from pydo.gateway.providers import BaseProvider, default_provider + +from .custom_operations import ( + AsyncCodeOperations, + AsyncGatewayTransport, + AsyncMCPTransport, + AsyncToolsOperations, + async_execute_tool_calls, +) + + +class AsyncGatewayResources: + """Async Action Gateway surface attached at ``client.gateway``.""" + + def __init__( + self, + parent_client: Any, + *, + gateway_endpoint: Optional[str] = None, + provider: Optional[BaseProvider] = None, + transport: Optional[AsyncGatewayTransport] = None, + ): + if transport is None: + proxy = _BaseURLProxy( + parent_client._client, + resolve_gateway_base_url(gateway_endpoint), + ) + transport = AsyncMCPTransport(proxy) + self._transport = transport + self.provider = provider or default_provider() + self.tools = AsyncToolsOperations(transport, self.provider) + self.code = AsyncCodeOperations(transport) + + @property + def base_url(self) -> Optional[str]: + proxy = getattr(self._transport, "_client", None) + return getattr(proxy, "_base_url", None) + + async def handle_tool_calls( + self, + response: Any, + *, + rationale: Optional[str] = None, + ) -> List[Any]: + """Async twin of :meth:`pydo.gateway.GatewayResources.handle_tool_calls`.""" + calls = self.provider.extract_tool_calls(response) + if not calls: + return [] + results = await async_execute_tool_calls(calls, self.tools, rationale=rationale) + return self.provider.format_tool_results(calls, results) + + async def execute_tool_calls( + self, + calls: Sequence[ToolCall], + *, + rationale: Optional[str] = None, + ) -> List[Any]: + """Execute pre-extracted :class:`ToolCall` objects; return raw outputs.""" + return await async_execute_tool_calls(calls, self.tools, rationale=rationale) + + +__all__ = [ + "AsyncGatewayResources", + "AsyncGatewayTransport", + "AsyncMCPTransport", + "AsyncToolsOperations", + "AsyncCodeOperations", + "async_execute_tool_calls", +] diff --git a/src/pydo/aio/gateway/custom_operations.py b/src/pydo/aio/gateway/custom_operations.py new file mode 100644 index 00000000..f6c2d1fb --- /dev/null +++ b/src/pydo/aio/gateway/custom_operations.py @@ -0,0 +1,271 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Async Action Gateway operations (mirror of :mod:`pydo.gateway`).""" + +from __future__ import annotations + +import itertools +from typing import Any, Dict, List, Optional, Sequence, Union + +from azure.core.rest import HttpRequest + +from pydo.custom_extensions import _wrap +from pydo.gateway.custom_models import ( + META_CODE, + META_INVOKE, + META_SEARCH, + META_TOOL_NAMES, + GatewayToolError, +) +from pydo.gateway.custom_operations import ( + QueryInput, + ToolSpecInput, + _normalize_queries, + _normalize_tool_specs, + _result_output_or_raise, + _flatten_search_results, + _tool_name, +) +from pydo.gateway.providers import _error_payload, _get +from pydo.gateway.transport import ( + _MCP_HEADERS, + _MCP_META_PATH, + _MCP_PATH, + _parse_jsonrpc, + _raise_gateway_http_error, + _unwrap_call_result, +) + + +class AsyncGatewayTransport: + """Async counterpart of :class:`pydo.gateway.transport.GatewayTransport`.""" + + async def list_tools(self, *, meta: bool) -> List[Any]: + raise NotImplementedError + + async def call_tool( + self, name: str, arguments: Dict[str, Any], *, meta: bool + ) -> Any: + raise NotImplementedError + + +class AsyncMCPTransport(AsyncGatewayTransport): + """Async JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" + + def __init__(self, base_url_proxy: Any): + self._client = base_url_proxy + self._ids = itertools.count(1) + + async def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + request = HttpRequest( + "POST", + path, + headers=dict(_MCP_HEADERS), + json=payload, + ) + request.url = self._client.format_url(request.url) + pipeline_response = await self._client._pipeline.run(request) + response = pipeline_response.http_response + if response.status_code != 200: + try: + await response.read() + except Exception: # noqa: BLE001 + pass + _raise_gateway_http_error(response) + body = await response.read() + return _parse_jsonrpc(body) + + async def _rpc( + self, + method: str, + params: Optional[Dict[str, Any]] = None, + *, + meta: bool, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "jsonrpc": "2.0", + "id": next(self._ids), + "method": method, + } + if params is not None: + payload["params"] = params + return await self._post(_MCP_META_PATH if meta else _MCP_PATH, payload) + + async def list_tools(self, *, meta: bool) -> List[Any]: + result = await self._rpc("tools/list", meta=meta) + return _wrap(result.get("tools") or []) + + async def call_tool( + self, name: str, arguments: Dict[str, Any], *, meta: bool + ) -> Any: + result = await self._rpc( + "tools/call", + {"name": name, "arguments": arguments or {}}, + meta=meta, + ) + return _unwrap_call_result(result) + + +class AsyncToolsOperations: + """Async Action Gateway tool discovery and invocation.""" + + def __init__(self, transport: AsyncGatewayTransport, provider: Any = None): + self._transport = transport + self._provider = provider + + async def list(self, *, include_all: bool = False) -> Any: + return await self._transport.list_tools(meta=not include_all) + + async def search( + self, + queries: Union[QueryInput, Sequence[QueryInput]], + *, + providers: Optional[Sequence[str]] = None, + tags: Optional[Sequence[str]] = None, + limit: Optional[int] = None, + ) -> Any: + arguments: Dict[str, Any] = {"queries": _normalize_queries(queries)} + if providers: + arguments["providers"] = list(providers) + if tags: + arguments["tags"] = list(tags) + if limit is not None: + arguments["limit"] = limit + return await self._transport.call_tool(META_SEARCH, arguments, meta=True) + + async def invoke( + self, + tools: Sequence[ToolSpecInput], + *, + rationale: Optional[str] = None, + ) -> Any: + arguments: Dict[str, Any] = {"tools": _normalize_tool_specs(tools)} + if rationale: + arguments["rationale"] = rationale + return await self._transport.call_tool(META_INVOKE, arguments, meta=True) + + async def invoke_one( + self, + name: str, + arguments: Optional[Dict[str, Any]] = None, + *, + rationale: Optional[str] = None, + ) -> Any: + envelope = await self.invoke( + [{"tool": name, "arguments": arguments or {}}], + rationale=rationale, + ) + get = getattr(envelope, "get", None) + results = (get("results") if get else None) or [] + if not results: + raise GatewayToolError(f"invoke of {name!r} returned no results") + first = results[0] + item_result = (getattr(first, "get", lambda *_: first)("result")) or first + return _result_output_or_raise(item_result, name) + + async def call(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> Any: + return await self._transport.call_tool(name, arguments or {}, meta=False) + + async def __call__( + self, + *, + include_all: bool = False, + names: Optional[Sequence[str]] = None, + search: Optional[Union[QueryInput, Sequence[QueryInput]]] = None, + providers: Optional[Sequence[str]] = None, + tags: Optional[Sequence[str]] = None, + limit: Optional[int] = None, + ) -> List[Any]: + if self._provider is None: + raise RuntimeError( + "no gateway provider configured; pass gateway_provider= to " + "Client() or use tools.list()/tools.invoke() directly" + ) + if search is not None: + payload = await self.search( + search, providers=providers, tags=tags, limit=limit + ) + catalog: List[Any] = _flatten_search_results(payload) + else: + wants_concrete = include_all or bool(names) + tools = await self.list(include_all=wants_concrete) + if names: + wanted = set(names) + tools = [t for t in tools if _tool_name(t) in wanted] + missing = wanted - {_tool_name(t) for t in tools} + if missing: + raise LookupError(f"tools not found in catalog: {sorted(missing)}") + catalog = list(tools) + return self._provider.wrap_tools(catalog) + + +class AsyncCodeOperations: + """Async ephemeral Python sandbox execution (``action.code``).""" + + def __init__(self, transport: AsyncGatewayTransport): + self._transport = transport + + async def execute(self, code: str, *, thought: Optional[str] = None) -> Any: + if not code or not code.strip(): + raise ValueError("code is empty") + arguments: Dict[str, Any] = {"code": code} + if thought: + arguments["thought"] = thought + return await self._transport.call_tool(META_CODE, arguments, meta=True) + + +async def async_execute_tool_calls( + calls: Sequence[Any], + tools_operations: AsyncToolsOperations, + *, + rationale: Optional[str] = None, +) -> List[Any]: + """Async twin of :func:`pydo.gateway.providers.execute_tool_calls`.""" + results: List[Any] = [None] * len(calls) + concrete: List[int] = [] + + for index, call in enumerate(calls): + if call.name in META_TOOL_NAMES: + try: + results[index] = await tools_operations._transport.call_tool( + call.name, call.arguments, meta=True + ) + except GatewayToolError as exc: + results[index] = _error_payload(exc) + else: + concrete.append(index) + + if concrete: + batch = [ + {"tool": calls[i].name, "arguments": calls[i].arguments} for i in concrete + ] + envelope = await tools_operations.invoke(batch, rationale=rationale) + items = (_get(envelope, "results") or []) if envelope is not None else [] + for position, index in enumerate(concrete): + if position < len(items): + item = items[position] + item_result = _get(item, "result") or item + status = _get(item_result, "status") + if status and status != "succeeded": + results[index] = { + "error": _get(item_result, "error") + or {"message": f"tool {calls[index].name!r} failed"} + } + else: + results[index] = _get(item_result, "output") + else: + results[index] = { + "error": {"message": "no result returned for this tool call"} + } + return results + + +__all__ = [ + "AsyncGatewayTransport", + "AsyncMCPTransport", + "AsyncToolsOperations", + "AsyncCodeOperations", + "async_execute_tool_calls", +] diff --git a/src/pydo/gateway/__init__.py b/src/pydo/gateway/__init__.py new file mode 100644 index 00000000..cd7b4605 --- /dev/null +++ b/src/pydo/gateway/__init__.py @@ -0,0 +1,137 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway API — hand-written; preserved across ``make generate``. + +Exposes tool discovery/invocation and sandboxed code execution over the +gateway's MCP endpoints, plus Composio-style providers that make gateway +tools plug directly into pydo's inference surfaces (chat completions, +messages, responses). +""" + +from __future__ import annotations + +import os +from typing import Any, List, Optional, Sequence + +from pydo.custom_extensions import _BaseURLProxy + +from .custom_models import ( + META_CODE, + META_INVOKE, + META_SEARCH, + META_TOOL_NAMES, + GatewayProtocolError, + GatewayToolError, + RecoveryHint, + ToolCall, + ToolErrorClass, + ToolResultStatus, +) +from .custom_operations import CodeOperations, ToolsOperations +from .providers import ( + BaseProvider, + ChatCompletionsProvider, + MessagesProvider, + ResponsesProvider, + default_provider, + execute_tool_calls, +) +from .transport import MCP_PROTOCOL_VERSION, GatewayTransport, MCPTransport + +DEFAULT_GATEWAY_BASE_URL = "https://actions.do-ai.run" +_ENV_VAR = "PYDO_GATEWAY_ENDPOINT" + + +def resolve_gateway_base_url(explicit: Optional[str] = None) -> str: + url = explicit or os.environ.get(_ENV_VAR) or DEFAULT_GATEWAY_BASE_URL + url = url.rstrip("/") + if "://" not in url: + url = f"https://{url}" + return url + + +class GatewayResources: + """Action Gateway surface attached at ``client.gateway``.""" + + def __init__( + self, + parent_client: Any, + *, + gateway_endpoint: Optional[str] = None, + provider: Optional[BaseProvider] = None, + transport: Optional[GatewayTransport] = None, + ): + if transport is None: + proxy = _BaseURLProxy( + parent_client._client, + resolve_gateway_base_url(gateway_endpoint), + ) + transport = MCPTransport(proxy) + self._transport = transport + self.provider = provider or default_provider() + self.tools = ToolsOperations(transport, self.provider) + self.code = CodeOperations(transport) + + @property + def base_url(self) -> Optional[str]: + proxy = getattr(self._transport, "_client", None) + return getattr(proxy, "_base_url", None) + + def handle_tool_calls( + self, + response: Any, + *, + rationale: Optional[str] = None, + ) -> List[Any]: + """Execute the tool calls in an inference response. + + Extracts tool calls using the configured provider, executes them + against the gateway (meta-tools directly; concrete tools batched + through one ``action.invoke``), and returns vendor-formatted + messages/items ready to append to the conversation. Returns an + empty list when the response contains no tool calls. + """ + calls = self.provider.extract_tool_calls(response) + if not calls: + return [] + results = execute_tool_calls(calls, self.tools, rationale=rationale) + return self.provider.format_tool_results(calls, results) + + def execute_tool_calls( + self, + calls: Sequence[ToolCall], + *, + rationale: Optional[str] = None, + ) -> List[Any]: + """Execute pre-extracted :class:`ToolCall` objects; return raw outputs.""" + return execute_tool_calls(calls, self.tools, rationale=rationale) + + +__all__ = [ + "GatewayResources", + "ToolsOperations", + "CodeOperations", + "GatewayTransport", + "MCPTransport", + "MCP_PROTOCOL_VERSION", + "BaseProvider", + "ChatCompletionsProvider", + "MessagesProvider", + "ResponsesProvider", + "default_provider", + "execute_tool_calls", + "ToolCall", + "GatewayToolError", + "GatewayProtocolError", + "ToolErrorClass", + "ToolResultStatus", + "RecoveryHint", + "META_SEARCH", + "META_INVOKE", + "META_CODE", + "META_TOOL_NAMES", + "DEFAULT_GATEWAY_BASE_URL", + "resolve_gateway_base_url", +] diff --git a/src/pydo/gateway/custom_models.py b/src/pydo/gateway/custom_models.py new file mode 100644 index 00000000..7598f39a --- /dev/null +++ b/src/pydo/gateway/custom_models.py @@ -0,0 +1,140 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway constants, errors, and shared value types.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +# Meta-tool names exposed on the gateway's ``/mcp/meta`` endpoint. +META_SEARCH = "action.search" +META_INVOKE = "action.invoke" +META_CODE = "action.code" +META_TOOL_NAMES = frozenset({META_SEARCH, META_INVOKE, META_CODE}) + + +class ToolResultStatus: + """Status of a single tool invocation envelope.""" + + SUCCEEDED = "succeeded" + FAILED = "failed" + + +class ToolErrorClass: + """Closed error taxonomy for tool invocation failures.""" + + INVALID_ARGUMENT = "invalid_argument" + UNAUTHORIZED = "unauthorized" + FORBIDDEN = "forbidden" + RATE_LIMITED = "rate_limited" + TIMEOUT = "timeout" + UPSTREAM_ERROR = "upstream_error" + OUTPUT_TOO_LARGE = "output_too_large" + EXECUTION_FAILED = "execution_failed" + UNAVAILABLE = "unavailable" + + +class RecoveryHint: + """Machine-routable hint on how a caller should recover from a failure.""" + + FIX_ARGS = "fix_args" + RETRY = "retry" + BACKOFF = "backoff" + ESCALATE = "escalate" + + +class GatewayToolError(RuntimeError): + """A tool invocation failed (gateway ``ToolResult`` error envelope). + + Raised when a single-tool operation (``invoke_one``, ``code.execute``, + ``tools.call``) fails, or when the MCP result reports ``isError``. + Batch ``invoke`` calls do NOT raise per-item failures; inspect the + envelope instead. + """ + + def __init__( + self, + message: str, + *, + error_class: Optional[str] = None, + retriable: Optional[bool] = None, + recovery_hint: Optional[str] = None, + invocation_id: Optional[str] = None, + details: Optional[Any] = None, + ): + super().__init__(message) + self.message = message + self.error_class = error_class + self.retriable = retriable + self.recovery_hint = recovery_hint + self.invocation_id = invocation_id + self.details = details + + @classmethod + def from_error_payload( + cls, + error: Dict[str, Any], + *, + invocation_id: Optional[str] = None, + ) -> "GatewayToolError": + return cls( + error.get("message") or "tool invocation failed", + error_class=error.get("class"), + retriable=error.get("retriable"), + recovery_hint=error.get("recovery_hint"), + invocation_id=invocation_id, + details=error, + ) + + +class GatewayProtocolError(RuntimeError): + """A JSON-RPC protocol-level error from the gateway MCP endpoint.""" + + def __init__( + self, + message: str, + *, + code: Optional[int] = None, + data: Optional[Any] = None, + ): + super().__init__(message) + self.message = message + self.code = code + self.data = data + + +class ToolCall: + """A normalized tool call extracted from an inference response. + + ``arguments`` is always a decoded ``dict`` (providers JSON-decode the + vendor's string encoding when needed). + """ + + __slots__ = ("call_id", "name", "arguments") + + def __init__(self, call_id: str, name: str, arguments: Dict[str, Any]): + self.call_id = call_id + self.name = name + self.arguments = arguments + + def __repr__(self) -> str: # pragma: no cover - debug aid + return ( + f"ToolCall(call_id={self.call_id!r}, name={self.name!r}, " + f"arguments={self.arguments!r})" + ) + + +__all__: List[str] = [ + "META_SEARCH", + "META_INVOKE", + "META_CODE", + "META_TOOL_NAMES", + "ToolResultStatus", + "ToolErrorClass", + "RecoveryHint", + "GatewayToolError", + "GatewayProtocolError", + "ToolCall", +] diff --git a/src/pydo/gateway/custom_operations.py b/src/pydo/gateway/custom_operations.py new file mode 100644 index 00000000..dc89a249 --- /dev/null +++ b/src/pydo/gateway/custom_operations.py @@ -0,0 +1,287 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway operations (tools + sandboxed code execution). + +Every method delegates to a :class:`~pydo.gateway.transport.GatewayTransport`, +so the public return shapes hold regardless of the underlying wire protocol +(MCP JSON-RPC today, REST later). +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Union + +from .custom_models import ( + META_CODE, + META_INVOKE, + META_SEARCH, + GatewayToolError, + ToolResultStatus, +) +from .transport import GatewayTransport + +_MAX_SEARCH_QUERIES = 5 +_MAX_INVOKE_TOOLS = 10 + +QueryInput = Union[str, Dict[str, Any]] +ToolSpecInput = Dict[str, Any] + + +def _normalize_queries( + queries: Union[QueryInput, Sequence[QueryInput]], +) -> List[Dict[str, Any]]: + """Accept a single use-case str, a list of strs, or dicts with ``use_case``.""" + if isinstance(queries, (str, dict)): + queries = [queries] + normalized: List[Dict[str, Any]] = [] + for query in queries: + if isinstance(query, str): + entry: Dict[str, Any] = {"use_case": query} + elif isinstance(query, dict): + if not query.get("use_case"): + raise ValueError("each search query dict requires a 'use_case'") + entry = {"use_case": query["use_case"]} + if query.get("known_fields"): + entry["known_fields"] = query["known_fields"] + else: + raise TypeError("queries must be str or dict entries") + normalized.append(entry) + if not 1 <= len(normalized) <= _MAX_SEARCH_QUERIES: + raise ValueError(f"search accepts between 1 and {_MAX_SEARCH_QUERIES} queries") + return normalized + + +def _normalize_tool_specs(tools: Sequence[ToolSpecInput]) -> List[Dict[str, Any]]: + """Normalize invoke entries; ``tool_slug`` is accepted as alias for ``tool``.""" + normalized: List[Dict[str, Any]] = [] + for spec in tools: + if not isinstance(spec, dict): + raise TypeError( + "each invoke entry must be a dict like " + "{'tool': name, 'arguments': {...}}" + ) + name = spec.get("tool") or spec.get("tool_slug") + if not name: + raise ValueError("each invoke entry requires a 'tool' name") + normalized.append({"tool": name, "arguments": spec.get("arguments") or {}}) + if not 1 <= len(normalized) <= _MAX_INVOKE_TOOLS: + raise ValueError(f"invoke accepts between 1 and {_MAX_INVOKE_TOOLS} tools") + return normalized + + +def _result_output_or_raise(item_result: Any, tool_name: str) -> Any: + """Unwrap one invoke ``ToolResult`` envelope; raise on failure.""" + get = getattr(item_result, "get", None) + if get is None: + return item_result + status = get("status") + if status and status != ToolResultStatus.SUCCEEDED: + error = get("error") or {} + raise GatewayToolError.from_error_payload( + dict(error) if error else {"message": f"tool {tool_name!r} failed"}, + invocation_id=get("invocation_id"), + ) + return get("output") + + +class ToolsOperations: + """Action Gateway tool discovery and invocation. + + Calling the instance itself (``client.gateway.tools()``) returns + provider-formatted tool definitions ready for an inference ``tools=`` + parameter — see :mod:`pydo.gateway.providers`. + """ + + def __init__(self, transport: GatewayTransport, provider: Any = None): + self._transport = transport + self._provider = provider + + # -- discovery --------------------------------------------------------- + + def list(self, *, include_all: bool = False) -> Any: + """List available tools. + + By default returns the three meta-tools (``action.search``, + ``action.invoke``, ``action.code``) — the intended agent workflow. + Pass ``include_all=True`` for the full concrete tool catalog. + """ + return self._transport.list_tools(meta=not include_all) + + def search( + self, + queries: Union[QueryInput, Sequence[QueryInput]], + *, + providers: Optional[Sequence[str]] = None, + tags: Optional[Sequence[str]] = None, + limit: Optional[int] = None, + ) -> Any: + """Search the tool catalog by use case (``action.search``). + + :param queries: A use-case string, a list of strings, or dicts with + ``use_case`` (and optional ``known_fields``). 1–5 queries. + :param providers: Optional provider filters (e.g. ``["exa"]``). + :param tags: Optional tag filters. + :param limit: Per-query result cap. + """ + arguments: Dict[str, Any] = {"queries": _normalize_queries(queries)} + if providers: + arguments["providers"] = list(providers) + if tags: + arguments["tags"] = list(tags) + if limit is not None: + arguments["limit"] = limit + return self._transport.call_tool(META_SEARCH, arguments, meta=True) + + # -- execution --------------------------------------------------------- + + def invoke( + self, + tools: Sequence[ToolSpecInput], + *, + rationale: Optional[str] = None, + ) -> Any: + """Invoke 1–10 tools in parallel (``action.invoke``). + + Returns the full envelope (``total_count`` / ``success_count`` / + ``error_count`` / ``results[]``). Per-tool failures are reported + inside the envelope and do NOT raise. + """ + arguments: Dict[str, Any] = {"tools": _normalize_tool_specs(tools)} + if rationale: + arguments["rationale"] = rationale + return self._transport.call_tool(META_INVOKE, arguments, meta=True) + + def invoke_one( + self, + name: str, + arguments: Optional[Dict[str, Any]] = None, + *, + rationale: Optional[str] = None, + ) -> Any: + """Invoke a single tool and return its output directly. + + Raises :class:`GatewayToolError` if the tool failed. + """ + envelope = self.invoke( + [{"tool": name, "arguments": arguments or {}}], + rationale=rationale, + ) + get = getattr(envelope, "get", None) + results = (get("results") if get else None) or [] + if not results: + raise GatewayToolError(f"invoke of {name!r} returned no results") + first = results[0] + item_result = (getattr(first, "get", lambda *_: first)("result")) or first + return _result_output_or_raise(item_result, name) + + def call(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> Any: + """Call one concrete catalog tool directly (``tools/call`` on ``/mcp``). + + Unlike :meth:`invoke`, the result is the tool's output payload with + no invoke envelope; failures raise :class:`GatewayToolError`. + """ + return self._transport.call_tool(name, arguments or {}, meta=False) + + # -- inference integration (Composio-style) ----------------------------- + + def __call__( + self, + *, + include_all: bool = False, + names: Optional[Sequence[str]] = None, + search: Optional[Union[QueryInput, Sequence[QueryInput]]] = None, + providers: Optional[Sequence[str]] = None, + tags: Optional[Sequence[str]] = None, + limit: Optional[int] = None, + ) -> List[Any]: + """Return provider-formatted tool definitions for ``tools=``. + + By default wraps the three meta-tools so the model drives the + search → invoke → code workflow itself. Pass ``include_all=True``, + ``names=``, or ``search=`` to wrap concrete catalog tools instead. + """ + if self._provider is None: + raise RuntimeError( + "no gateway provider configured; pass gateway_provider= to " + "Client() or use tools.list()/tools.invoke() directly" + ) + catalog = self._fetch_catalog( + include_all=include_all, + names=names, + search=search, + providers=providers, + tags=tags, + limit=limit, + ) + return self._provider.wrap_tools(catalog) + + def _fetch_catalog( + self, + *, + include_all: bool, + names: Optional[Sequence[str]], + search: Optional[Union[QueryInput, Sequence[QueryInput]]], + providers: Optional[Sequence[str]], + tags: Optional[Sequence[str]], + limit: Optional[int], + ) -> List[Any]: + if search is not None: + payload = self.search(search, providers=providers, tags=tags, limit=limit) + return _flatten_search_results(payload) + wants_concrete = include_all or bool(names) + tools = self.list(include_all=wants_concrete) + if names: + wanted = set(names) + tools = [t for t in tools if _tool_name(t) in wanted] + missing = wanted - {_tool_name(t) for t in tools} + if missing: + raise LookupError(f"tools not found in catalog: {sorted(missing)}") + return list(tools) + + +def _tool_name(tool: Any) -> Optional[str]: + get = getattr(tool, "get", None) + return get("name") if get else getattr(tool, "name", None) + + +def _flatten_search_results(payload: Any) -> List[Any]: + """Flatten an ``action.search`` payload into a deduplicated tool list.""" + get = getattr(payload, "get", None) + groups = (get("results") if get else None) or [] + seen: Dict[str, Any] = {} + for group in groups: + group_get = getattr(group, "get", None) + matches = (group_get("results") if group_get else None) or [] + for match in matches: + name = _tool_name(match) + if name and name not in seen: + seen[name] = match + return list(seen.values()) + + +class CodeOperations: + """Ephemeral Python sandbox execution (``action.code``).""" + + def __init__(self, transport: GatewayTransport): + self._transport = transport + + def execute(self, code: str, *, thought: Optional[str] = None) -> Any: + """Run Python code in the gateway sandbox. + + Returns the execution output (``stdout`` / ``stderr`` / + ``exit_code``). Raises :class:`GatewayToolError` on sandbox failure. + """ + if not code or not code.strip(): + raise ValueError("code is empty") + arguments: Dict[str, Any] = {"code": code} + if thought: + arguments["thought"] = thought + return self._transport.call_tool(META_CODE, arguments, meta=True) + + +__all__ = [ + "ToolsOperations", + "CodeOperations", +] diff --git a/src/pydo/gateway/providers.py b/src/pydo/gateway/providers.py new file mode 100644 index 00000000..6a0b1c8d --- /dev/null +++ b/src/pydo/gateway/providers.py @@ -0,0 +1,300 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Inference providers — translate gateway tools to/from vendor formats. + +pydo exposes three inference surfaces with three different tool wire +formats. A provider owns both directions of translation for one of them +(the Composio pattern): + +* :class:`ChatCompletionsProvider` — ``client.chat.completions.create`` + (OpenAI chat completions format). The default. +* :class:`MessagesProvider` — ``client.messages.create`` (Anthropic + Messages format). +* :class:`ResponsesProvider` — ``client.responses.create`` (OpenAI + Responses format). + +Usage:: + + client = Client(token=..., gateway_provider=MessagesProvider()) + tools = client.gateway.tools() # vendor tools= format + resp = client.messages.create(..., tools=tools, messages=messages) + messages += client.gateway.handle_tool_calls(resp) +""" + +from __future__ import annotations + +import json as _json +from typing import Any, Dict, List, Optional, Sequence + +from .custom_models import ToolCall + + +def _get(obj: Any, key: str, default: Any = None) -> Any: + """Uniform field access over dicts/DotDicts and attribute objects.""" + getter = getattr(obj, "get", None) + if getter is not None: + return getter(key, default) + return getattr(obj, key, default) + + +def _decode_arguments(arguments: Any) -> Dict[str, Any]: + if isinstance(arguments, str): + if not arguments.strip(): + return {} + return _json.loads(arguments) + if isinstance(arguments, dict): + return dict(arguments) + return {} + + +def _tool_fields(tool: Any) -> Dict[str, Any]: + """Extract canonical fields from a gateway catalog/meta tool definition.""" + return { + "name": _get(tool, "name"), + "description": _get(tool, "description") or _get(tool, "title") or "", + "parameters": dict(_get(tool, "inputSchema") or {"type": "object"}), + } + + +def _result_to_content(result: Any) -> str: + """Serialize one tool result (output or error payload) for the model.""" + if isinstance(result, str): + return result + try: + return _json.dumps(result, default=str) + except (TypeError, ValueError): + return str(result) + + +class BaseProvider: + """Translation contract between gateway tools and one inference surface.""" + + name = "base" + + def wrap_tools(self, catalog_tools: Sequence[Any]) -> List[Dict[str, Any]]: + """Convert canonical gateway tool defs to the vendor ``tools=`` format.""" + raise NotImplementedError + + def extract_tool_calls(self, response: Any) -> List[ToolCall]: + """Extract normalized tool calls from a vendor response object.""" + raise NotImplementedError + + def format_tool_results( + self, + calls: Sequence[ToolCall], + results: Sequence[Any], + ) -> List[Dict[str, Any]]: + """Convert invocation outputs into vendor-shaped result messages/items.""" + raise NotImplementedError + + +class ChatCompletionsProvider(BaseProvider): + """OpenAI chat-completions format (``client.chat.completions.create``).""" + + name = "chat.completions" + + def wrap_tools(self, catalog_tools: Sequence[Any]) -> List[Dict[str, Any]]: + return [ + {"type": "function", "function": _tool_fields(tool)} + for tool in catalog_tools + ] + + def extract_tool_calls(self, response: Any) -> List[ToolCall]: + choices = _get(response, "choices") or [] + if not choices: + return [] + message = _get(choices[0], "message") or {} + calls = [] + for tool_call in _get(message, "tool_calls") or []: + function = _get(tool_call, "function") or {} + calls.append( + ToolCall( + call_id=_get(tool_call, "id") or "", + name=_get(function, "name") or "", + arguments=_decode_arguments(_get(function, "arguments")), + ) + ) + return calls + + def format_tool_results( + self, + calls: Sequence[ToolCall], + results: Sequence[Any], + ) -> List[Dict[str, Any]]: + return [ + { + "role": "tool", + "tool_call_id": call.call_id, + "content": _result_to_content(result), + } + for call, result in zip(calls, results) + ] + + +class MessagesProvider(BaseProvider): + """Anthropic Messages format (``client.messages.create``).""" + + name = "messages" + + def wrap_tools(self, catalog_tools: Sequence[Any]) -> List[Dict[str, Any]]: + wrapped = [] + for tool in catalog_tools: + fields = _tool_fields(tool) + wrapped.append( + { + "name": fields["name"], + "description": fields["description"], + "input_schema": fields["parameters"], + } + ) + return wrapped + + def extract_tool_calls(self, response: Any) -> List[ToolCall]: + calls = [] + for block in _get(response, "content") or []: + if _get(block, "type") != "tool_use": + continue + calls.append( + ToolCall( + call_id=_get(block, "id") or "", + name=_get(block, "name") or "", + arguments=_decode_arguments(_get(block, "input")), + ) + ) + return calls + + def format_tool_results( + self, + calls: Sequence[ToolCall], + results: Sequence[Any], + ) -> List[Dict[str, Any]]: + if not calls: + return [] + content = [ + { + "type": "tool_result", + "tool_use_id": call.call_id, + "content": _result_to_content(result), + } + for call, result in zip(calls, results) + ] + # Anthropic expects all tool results in a single user turn. + return [{"role": "user", "content": content}] + + +class ResponsesProvider(BaseProvider): + """OpenAI Responses format (``client.responses.create``).""" + + name = "responses" + + def wrap_tools(self, catalog_tools: Sequence[Any]) -> List[Dict[str, Any]]: + return [{"type": "function", **_tool_fields(tool)} for tool in catalog_tools] + + def extract_tool_calls(self, response: Any) -> List[ToolCall]: + calls = [] + for item in _get(response, "output") or []: + if _get(item, "type") != "function_call": + continue + calls.append( + ToolCall( + call_id=_get(item, "call_id") or _get(item, "id") or "", + name=_get(item, "name") or "", + arguments=_decode_arguments(_get(item, "arguments")), + ) + ) + return calls + + def format_tool_results( + self, + calls: Sequence[ToolCall], + results: Sequence[Any], + ) -> List[Dict[str, Any]]: + return [ + { + "type": "function_call_output", + "call_id": call.call_id, + "output": _result_to_content(result), + } + for call, result in zip(calls, results) + ] + + +def default_provider() -> BaseProvider: + return ChatCompletionsProvider() + + +def execute_tool_calls( + calls: Sequence[ToolCall], + tools_operations: Any, + *, + rationale: Optional[str] = None, +) -> List[Any]: + """Execute normalized tool calls against the gateway. + + Meta-tool calls (``action.search`` / ``action.invoke`` / ``action.code``) + go straight through; concrete tool names are batched through one + ``action.invoke``. Per-tool failures become structured error payloads + rather than raising, so the model can observe and recover. + """ + from .custom_models import META_TOOL_NAMES, GatewayToolError + + results: List[Any] = [None] * len(calls) + concrete: List[int] = [] + + for index, call in enumerate(calls): + if call.name in META_TOOL_NAMES: + try: + results[index] = tools_operations._transport.call_tool( + call.name, call.arguments, meta=True + ) + except GatewayToolError as exc: + results[index] = _error_payload(exc) + else: + concrete.append(index) + + if concrete: + batch = [ + {"tool": calls[i].name, "arguments": calls[i].arguments} for i in concrete + ] + envelope = tools_operations.invoke(batch, rationale=rationale) + items = (_get(envelope, "results") or []) if envelope is not None else [] + for position, index in enumerate(concrete): + if position < len(items): + item = items[position] + item_result = _get(item, "result") or item + status = _get(item_result, "status") + if status and status != "succeeded": + results[index] = { + "error": _get(item_result, "error") + or {"message": f"tool {calls[index].name!r} failed"} + } + else: + results[index] = _get(item_result, "output") + else: + results[index] = { + "error": {"message": "no result returned for this tool call"} + } + return results + + +def _error_payload(exc: Any) -> Dict[str, Any]: + return { + "error": { + "message": str(exc), + "class": getattr(exc, "error_class", None), + "retriable": getattr(exc, "retriable", None), + "recovery_hint": getattr(exc, "recovery_hint", None), + } + } + + +__all__ = [ + "BaseProvider", + "ChatCompletionsProvider", + "MessagesProvider", + "ResponsesProvider", + "default_provider", + "execute_tool_calls", +] diff --git a/src/pydo/gateway/transport.py b/src/pydo/gateway/transport.py new file mode 100644 index 00000000..af0712cc --- /dev/null +++ b/src/pydo/gateway/transport.py @@ -0,0 +1,232 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway wire layer. + +The public SDK surface (``ToolsOperations`` / ``CodeOperations``) only talks +to the small :class:`GatewayTransport` interface. Today the gateway is +consumed over its MCP JSON-RPC endpoints (``/mcp`` and ``/mcp/meta``); when +the REST compatibility endpoints ship, a ``RESTTransport`` implementing the +same two methods can be dropped in without changing any user-facing method +or return shape. + +The gateway's MCP handler runs stateless with JSON responses, so +:class:`MCPTransport` is a plain JSON-RPC 2.0 POST per request — no +``initialize`` handshake, no session ids, no SSE parsing. +""" + +from __future__ import annotations + +import itertools +import json as _json +from typing import Any, Dict, List, Optional + +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceExistsError, + ResourceNotFoundError, + ResourceNotModifiedError, + map_error, +) +from azure.core.rest import HttpRequest + +from pydo.custom_extensions import _wrap + +from .custom_models import GatewayProtocolError, GatewayToolError + +_ERROR_MAP = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, +} + +MCP_PROTOCOL_VERSION = "2025-06-18" + +_MCP_PATH = "/mcp" +_MCP_META_PATH = "/mcp/meta" + +_MCP_HEADERS = { + "Content-Type": "application/json", + "MCP-Protocol-Version": MCP_PROTOCOL_VERSION, + "Accept": "application/json, text/event-stream", +} + + +def _response_body_text(response: Any) -> str: + try: + if hasattr(response, "read"): + try: + response.read() + except Exception: # noqa: BLE001 + pass + body = response.text() if hasattr(response, "text") else response.body() + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + return body or "" + except Exception: # noqa: BLE001 — best-effort error detail for callers + return "" + + +def _raise_gateway_http_error(response: Any) -> None: + body = _response_body_text(response) + map_error( + status_code=response.status_code, + response=response, + error_map=_ERROR_MAP, + ) + message = body.strip() or getattr(response, "reason", None) or "request failed" + if response.status_code == 412: + message = ( + "team is not enabled for the Action Infra release " + f"(412 Precondition Failed): {message}" + ) + raise HttpResponseError(message=message, response=response) + + +def _content_text(content: Optional[List[Dict[str, Any]]]) -> str: + parts = [] + for block in content or []: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text") or "") + return "\n".join(p for p in parts if p) + + +def _unwrap_call_result(result: Dict[str, Any]) -> Any: + """Normalize an MCP ``tools/call`` result to its useful payload. + + Prefers ``structuredContent`` (typed payload); falls back to the joined + ``content`` text blocks (parsed as JSON when possible). Raises + :class:`GatewayToolError` when the tool reported ``isError``. + """ + if result.get("isError"): + structured = result.get("structuredContent") + error = None + if isinstance(structured, dict): + error = structured.get("error") or ( + structured if "message" in structured else None + ) + if error: + raise GatewayToolError.from_error_payload( + error, + invocation_id=( + structured.get("invocation_id") + if isinstance(structured, dict) + else None + ), + ) + raise GatewayToolError( + _content_text(result.get("content")) or "tool call failed" + ) + + structured = result.get("structuredContent") + if structured is not None: + return _wrap(structured) + + text = _content_text(result.get("content")) + try: + return _wrap(_json.loads(text)) + except (TypeError, ValueError): + return text + + +def _parse_jsonrpc(body: Any) -> Dict[str, Any]: + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + try: + envelope = _json.loads(body) + except (TypeError, ValueError) as exc: + raise GatewayProtocolError( + f"gateway returned a non-JSON response: {body!r}" + ) from exc + if not isinstance(envelope, dict): + raise GatewayProtocolError( + f"gateway returned an unexpected JSON-RPC envelope: {envelope!r}" + ) + error = envelope.get("error") + if error: + raise GatewayProtocolError( + error.get("message") or "JSON-RPC error", + code=error.get("code"), + data=error.get("data"), + ) + result = envelope.get("result") + if not isinstance(result, dict): + raise GatewayProtocolError( + f"gateway JSON-RPC response is missing a result: {envelope!r}" + ) + return result + + +class GatewayTransport: + """Swappable wire layer; MCP semantics are the lowest common denominator.""" + + def list_tools(self, *, meta: bool) -> List[Any]: + raise NotImplementedError + + def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: + raise NotImplementedError + + +class MCPTransport(GatewayTransport): + """JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" + + def __init__(self, base_url_proxy: Any): + self._client = base_url_proxy + self._ids = itertools.count(1) + + # -- wire plumbing ---------------------------------------------------- + + def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + request = HttpRequest( + "POST", + path, + headers=dict(_MCP_HEADERS), + json=payload, + ) + request.url = self._client.format_url(request.url) + pipeline_response = self._client._pipeline.run(request) + response = pipeline_response.http_response + if response.status_code != 200: + _raise_gateway_http_error(response) + body = response.text() if hasattr(response, "text") else response.body() + return _parse_jsonrpc(body) + + def _rpc( + self, + method: str, + params: Optional[Dict[str, Any]] = None, + *, + meta: bool, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "jsonrpc": "2.0", + "id": next(self._ids), + "method": method, + } + if params is not None: + payload["params"] = params + return self._post(_MCP_META_PATH if meta else _MCP_PATH, payload) + + # -- GatewayTransport ------------------------------------------------- + + def list_tools(self, *, meta: bool) -> List[Any]: + result = self._rpc("tools/list", meta=meta) + return _wrap(result.get("tools") or []) + + def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: + result = self._rpc( + "tools/call", + {"name": name, "arguments": arguments or {}}, + meta=meta, + ) + return _unwrap_call_result(result) + + +__all__ = [ + "GatewayTransport", + "MCPTransport", + "MCP_PROTOCOL_VERSION", +] diff --git a/tests/gateway/__init__.py b/tests/gateway/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py new file mode 100644 index 00000000..ab337271 --- /dev/null +++ b/tests/gateway/conftest.py @@ -0,0 +1,131 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Shared fakes for gateway tests — no network, fake pipeline plumbing.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import MagicMock + +from pydo.gateway import GatewayResources +from pydo.aio.gateway import AsyncGatewayResources + + +class FakeResponse: + def __init__(self, status_code: int, body: Any = None): + self.status_code = status_code + self.reason = "" + self.headers: dict = {} + if isinstance(body, (dict, list)): + self._body_bytes = json.dumps(body).encode("utf-8") + elif isinstance(body, str): + self._body_bytes = body.encode("utf-8") + elif isinstance(body, bytes): + self._body_bytes = body + else: + self._body_bytes = b"" + + def text(self) -> str: + return self._body_bytes.decode("utf-8") + + def body(self) -> bytes: + return self._body_bytes + + def read(self) -> bytes: + return self._body_bytes + + def close(self) -> None: + pass + + +class AsyncFakeResponse(FakeResponse): + async def read(self) -> bytes: # type: ignore[override] + return self._body_bytes + + +class FakePipeline: + def __init__(self, responses: List[FakeResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + return SimpleNamespace(http_response=self._responses.pop(0)) + + +class AsyncFakePipeline: + def __init__(self, responses: List[AsyncFakeResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + async def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + return SimpleNamespace(http_response=self._responses.pop(0)) + + +def jsonrpc_result(result: Any, *, rpc_id: int = 1) -> dict: + return {"jsonrpc": "2.0", "id": rpc_id, "result": result} + + +def jsonrpc_error(code: int, message: str, *, rpc_id: int = 1) -> dict: + return { + "jsonrpc": "2.0", + "id": rpc_id, + "error": {"code": code, "message": message}, + } + + +def call_result( + structured: Any = None, *, is_error: bool = False, text: str = "" +) -> dict: + result: dict = {"isError": is_error} + if structured is not None: + result["structuredContent"] = structured + if text: + result["content"] = [{"type": "text", "text": text}] + return result + + +def make_gateway(responses: List[FakeResponse], provider=None) -> GatewayResources: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = FakePipeline(responses) + return GatewayResources( + parent, + gateway_endpoint="https://actions.do-ai-test.run", + provider=provider, + ) + + +def make_async_gateway( + responses: List[AsyncFakeResponse], provider=None +) -> AsyncGatewayResources: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = AsyncFakePipeline(responses) + return AsyncGatewayResources( + parent, + gateway_endpoint="https://actions.do-ai-test.run", + provider=provider, + ) + + +def pipeline_of(gateway) -> Any: + return gateway._transport._client._original._pipeline + + +def sent_request(gateway, index: int = 0) -> Any: + return pipeline_of(gateway).calls[index].request + + +def sent_payload(gateway, index: int = 0) -> dict: + request = sent_request(gateway, index) + content = request.content + if isinstance(content, bytes): + content = content.decode("utf-8") + return json.loads(content) diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py new file mode 100644 index 00000000..a180011f --- /dev/null +++ b/tests/gateway/test_async_gateway.py @@ -0,0 +1,141 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Async smoke tests for :mod:`pydo.aio.gateway`.""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +from pydo.gateway import ChatCompletionsProvider, GatewayToolError + +from .conftest import ( + AsyncFakeResponse, + call_result, + jsonrpc_result, + make_async_gateway, +) + + +def _run(coro): + return asyncio.run(coro) + + +def _sent_payload(gateway, index=0): + pipeline = gateway._transport._client._original._pipeline + content = pipeline.calls[index].request.content + if isinstance(content, bytes): + content = content.decode("utf-8") + return json.loads(content) + + +def test_list_defaults_to_meta(): + gateway = make_async_gateway( + [AsyncFakeResponse(200, jsonrpc_result({"tools": [{"name": "action.search"}]}))] + ) + tools = _run(gateway.tools.list()) + assert tools[0].name == "action.search" + pipeline = gateway._transport._client._original._pipeline + assert pipeline.calls[0].request.url.endswith("/mcp/meta") + + +def test_invoke_and_invoke_one(): + envelope = { + "total_count": 1, + "success_count": 1, + "error_count": 0, + "results": [ + { + "index": 0, + "tool": "web_search", + "result": {"status": "succeeded", "output": {"answer": 7}}, + } + ], + } + gateway = make_async_gateway( + [AsyncFakeResponse(200, jsonrpc_result(call_result(envelope)))] + ) + output = _run(gateway.tools.invoke_one("web_search", {"query": "do"})) + assert output.answer == 7 + params = _sent_payload(gateway)["params"] + assert params["name"] == "action.invoke" + + +def test_code_execute_failure_raises(): + structured = {"error": {"class": "execution_failed", "message": "crash"}} + gateway = make_async_gateway( + [AsyncFakeResponse(200, jsonrpc_result(call_result(structured, is_error=True)))] + ) + with pytest.raises(GatewayToolError, match="crash"): + _run(gateway.code.execute("1/0")) + + +def test_tools_callable_and_handle_tool_calls(): + meta_tools = [ + { + "name": "action.search", + "description": "d", + "inputSchema": {"type": "object"}, + }, + { + "name": "action.invoke", + "description": "d", + "inputSchema": {"type": "object"}, + }, + {"name": "action.code", "description": "d", "inputSchema": {"type": "object"}}, + ] + envelope = { + "total_count": 1, + "success_count": 1, + "error_count": 0, + "results": [ + { + "index": 0, + "tool": "web_search", + "result": {"status": "succeeded", "output": {"ok": True}}, + } + ], + } + gateway = make_async_gateway( + [ + AsyncFakeResponse(200, jsonrpc_result({"tools": meta_tools})), + AsyncFakeResponse(200, jsonrpc_result(call_result(envelope))), + ], + provider=ChatCompletionsProvider(), + ) + + async def scenario(): + tools = await gateway.tools() + response = { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "web_search", + "arguments": '{"query": "do"}', + }, + } + ] + } + } + ] + } + messages = await gateway.handle_tool_calls(response) + return tools, messages + + tools, messages = _run(scenario()) + assert [t["function"]["name"] for t in tools] == [ + "action.search", + "action.invoke", + "action.code", + ] + assert messages[0]["role"] == "tool" + assert json.loads(messages[0]["content"]) == {"ok": True} diff --git a/tests/gateway/test_code.py b/tests/gateway/test_code.py new file mode 100644 index 00000000..36d4e50d --- /dev/null +++ b/tests/gateway/test_code.py @@ -0,0 +1,68 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :class:`pydo.gateway.custom_operations.CodeOperations`.""" + +from __future__ import annotations + +import pytest + +from pydo.gateway import GatewayToolError + +from .conftest import ( + FakeResponse, + call_result, + jsonrpc_result, + make_gateway, + sent_payload, + sent_request, +) + + +def test_execute_happy_path(): + output = {"stdout": "hello\n", "stderr": "", "exit_code": 0} + gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(output)))]) + result = gateway.code.execute("print('hello')", thought="say hello") + + request = sent_request(gateway) + assert request.url.endswith("/mcp/meta") + params = sent_payload(gateway)["params"] + assert params["name"] == "action.code" + assert params["arguments"] == { + "code": "print('hello')", + "thought": "say hello", + } + + assert result.stdout == "hello\n" + assert result.exit_code == 0 + + +def test_execute_omits_empty_thought(): + output = {"stdout": "", "stderr": "", "exit_code": 0} + gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(output)))]) + gateway.code.execute("pass") + assert "thought" not in sent_payload(gateway)["params"]["arguments"] + + +def test_execute_rejects_empty_code(): + gateway = make_gateway([]) + with pytest.raises(ValueError, match="empty"): + gateway.code.execute(" ") + + +def test_execute_sandbox_failure_raises(): + structured = { + "error": { + "class": "execution_failed", + "message": "sandbox crashed", + "retriable": False, + } + } + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result(structured, is_error=True)))] + ) + with pytest.raises(GatewayToolError, match="sandbox crashed") as excinfo: + gateway.code.execute("1/0") + assert excinfo.value.error_class == "execution_failed" diff --git a/tests/gateway/test_providers.py b/tests/gateway/test_providers.py new file mode 100644 index 00000000..22ef8f18 --- /dev/null +++ b/tests/gateway/test_providers.py @@ -0,0 +1,380 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :mod:`pydo.gateway.providers` and ``handle_tool_calls``.""" + +from __future__ import annotations + +import json + +import pytest + +from pydo.custom_extensions import _wrap +from pydo.gateway import ( + ChatCompletionsProvider, + MessagesProvider, + ResponsesProvider, +) + +from .conftest import ( + FakeResponse, + call_result, + jsonrpc_result, + make_gateway, + sent_payload, +) + +_CATALOG = [ + { + "name": "web_search", + "title": "Web Search", + "description": "Search the public web", + "inputSchema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + } +] + +_META_TOOLS = [ + { + "name": "action.search", + "description": "Find tools", + "inputSchema": {"type": "object"}, + }, + { + "name": "action.invoke", + "description": "Run tools", + "inputSchema": {"type": "object"}, + }, + { + "name": "action.code", + "description": "Run code", + "inputSchema": {"type": "object"}, + }, +] + + +# -- wrap_tools --------------------------------------------------------------- + + +def test_chat_completions_wrap_tools(): + tools = ChatCompletionsProvider().wrap_tools(_CATALOG) + assert tools == [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "Search the public web", + "parameters": _CATALOG[0]["inputSchema"], + }, + } + ] + + +def test_messages_wrap_tools(): + tools = MessagesProvider().wrap_tools(_CATALOG) + assert tools == [ + { + "name": "web_search", + "description": "Search the public web", + "input_schema": _CATALOG[0]["inputSchema"], + } + ] + + +def test_responses_wrap_tools(): + tools = ResponsesProvider().wrap_tools(_CATALOG) + assert tools == [ + { + "type": "function", + "name": "web_search", + "description": "Search the public web", + "parameters": _CATALOG[0]["inputSchema"], + } + ] + + +def test_wrap_tools_falls_back_to_title_and_empty_schema(): + tools = ChatCompletionsProvider().wrap_tools([{"name": "t", "title": "T"}]) + function = tools[0]["function"] + assert function["description"] == "T" + assert function["parameters"] == {"type": "object"} + + +# -- extract_tool_calls ------------------------------------------------------- + + +def _chat_response(arguments='{"query": "do"}'): + return { + "choices": [ + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "web_search", + "arguments": arguments, + }, + } + ], + } + } + ] + } + + +def _messages_response(): + return { + "content": [ + {"type": "text", "text": "let me check"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "web_search", + "input": {"query": "do"}, + }, + ] + } + + +def _responses_response(): + return { + "output": [ + { + "type": "function_call", + "call_id": "fc_1", + "name": "web_search", + "arguments": '{"query": "do"}', + } + ] + } + + +@pytest.mark.parametrize("wrap", [lambda x: x, _wrap], ids=["dict", "DotDict"]) +def test_chat_completions_extract(wrap): + calls = ChatCompletionsProvider().extract_tool_calls(wrap(_chat_response())) + assert len(calls) == 1 + assert calls[0].call_id == "call_1" + assert calls[0].name == "web_search" + assert calls[0].arguments == {"query": "do"} + + +@pytest.mark.parametrize("wrap", [lambda x: x, _wrap], ids=["dict", "DotDict"]) +def test_messages_extract(wrap): + calls = MessagesProvider().extract_tool_calls(wrap(_messages_response())) + assert len(calls) == 1 + assert calls[0].call_id == "toolu_1" + assert calls[0].arguments == {"query": "do"} + + +@pytest.mark.parametrize("wrap", [lambda x: x, _wrap], ids=["dict", "DotDict"]) +def test_responses_extract(wrap): + calls = ResponsesProvider().extract_tool_calls(wrap(_responses_response())) + assert len(calls) == 1 + assert calls[0].call_id == "fc_1" + assert calls[0].arguments == {"query": "do"} + + +def test_extract_returns_empty_without_tool_calls(): + assert ( + ChatCompletionsProvider().extract_tool_calls( + {"choices": [{"message": {"content": "hi"}}]} + ) + == [] + ) + assert MessagesProvider().extract_tool_calls({"content": []}) == [] + assert ResponsesProvider().extract_tool_calls({"output": []}) == [] + + +# -- format_tool_results ------------------------------------------------------ + + +def test_format_results_per_provider(): + provider = ChatCompletionsProvider() + calls = provider.extract_tool_calls(_chat_response()) + messages = provider.format_tool_results(calls, [{"answer": 1}]) + assert messages == [ + {"role": "tool", "tool_call_id": "call_1", "content": '{"answer": 1}'} + ] + + provider = MessagesProvider() + calls = provider.extract_tool_calls(_messages_response()) + messages = provider.format_tool_results(calls, [{"answer": 1}]) + assert messages[0]["role"] == "user" + assert messages[0]["content"][0]["type"] == "tool_result" + assert messages[0]["content"][0]["tool_use_id"] == "toolu_1" + + provider = ResponsesProvider() + calls = provider.extract_tool_calls(_responses_response()) + items = provider.format_tool_results(calls, [{"answer": 1}]) + assert items == [ + { + "type": "function_call_output", + "call_id": "fc_1", + "output": '{"answer": 1}', + } + ] + + +# -- tools() callable --------------------------------------------------------- + + +def test_tools_callable_wraps_meta_tools_by_default(): + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result({"tools": _META_TOOLS}))], + provider=ChatCompletionsProvider(), + ) + tools = gateway.tools() + assert [t["function"]["name"] for t in tools] == [ + "action.search", + "action.invoke", + "action.code", + ] + + +def test_tools_callable_include_all_wraps_catalog(): + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result({"tools": _CATALOG}))], + provider=ChatCompletionsProvider(), + ) + tools = gateway.tools(include_all=True) + assert tools[0]["function"]["name"] == "web_search" + + +def test_tools_callable_names_filter_and_missing(): + gateway = make_gateway( + [ + FakeResponse(200, jsonrpc_result({"tools": _CATALOG})), + FakeResponse(200, jsonrpc_result({"tools": _CATALOG})), + ], + provider=ChatCompletionsProvider(), + ) + tools = gateway.tools(names=["web_search"]) + assert len(tools) == 1 + with pytest.raises(LookupError, match="nope"): + gateway.tools(names=["nope"]) + + +def test_tools_callable_via_search(): + search_payload = { + "results": [ + { + "index": 1, + "use_case": "web", + "results": [_CATALOG[0], _CATALOG[0]], # dupes collapse + } + ] + } + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result(search_payload)))], + provider=ChatCompletionsProvider(), + ) + tools = gateway.tools(search="search the web", limit=2) + assert len(tools) == 1 + assert tools[0]["function"]["name"] == "web_search" + + +# -- handle_tool_calls -------------------------------------------------------- + + +def test_handle_tool_calls_batches_concrete_tools(): + envelope = { + "total_count": 1, + "success_count": 1, + "error_count": 0, + "results": [ + { + "index": 0, + "tool": "web_search", + "result": {"status": "succeeded", "output": {"answer": 42}}, + } + ], + } + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result(envelope)))], + provider=ChatCompletionsProvider(), + ) + messages = gateway.handle_tool_calls(_chat_response(), rationale="why not") + + params = sent_payload(gateway)["params"] + assert params["name"] == "action.invoke" + assert params["arguments"]["rationale"] == "why not" + assert params["arguments"]["tools"] == [ + {"tool": "web_search", "arguments": {"query": "do"}} + ] + + assert messages[0]["role"] == "tool" + assert messages[0]["tool_call_id"] == "call_1" + assert json.loads(messages[0]["content"]) == {"answer": 42} + + +def test_handle_tool_calls_routes_meta_tools_directly(): + response = { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "call_meta", + "function": { + "name": "action.search", + "arguments": '{"queries": [{"use_case": "x"}]}', + }, + } + ] + } + } + ] + } + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result({"results": []})))], + provider=ChatCompletionsProvider(), + ) + messages = gateway.handle_tool_calls(response) + params = sent_payload(gateway)["params"] + assert params["name"] == "action.search" + assert json.loads(messages[0]["content"]) == {"results": []} + + +def test_handle_tool_calls_surfaces_failures_as_content(): + envelope = { + "total_count": 1, + "success_count": 0, + "error_count": 1, + "results": [ + { + "index": 0, + "tool": "web_search", + "result": { + "status": "failed", + "error": {"class": "timeout", "message": "too slow"}, + }, + } + ], + } + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result(envelope)))], + provider=ChatCompletionsProvider(), + ) + messages = gateway.handle_tool_calls(_chat_response()) + content = json.loads(messages[0]["content"]) + assert content["error"]["class"] == "timeout" + + +def test_handle_tool_calls_no_calls_returns_empty(): + gateway = make_gateway([], provider=ChatCompletionsProvider()) + assert gateway.handle_tool_calls({"choices": [{"message": {}}]}) == [] + + +def test_tools_callable_requires_provider(): + gateway = make_gateway([], provider=None) + gateway.tools._provider = None + with pytest.raises(RuntimeError, match="provider"): + gateway.tools() diff --git a/tests/gateway/test_tools.py b/tests/gateway/test_tools.py new file mode 100644 index 00000000..86b364a2 --- /dev/null +++ b/tests/gateway/test_tools.py @@ -0,0 +1,207 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :class:`pydo.gateway.custom_operations.ToolsOperations`.""" + +from __future__ import annotations + +import pytest + +from pydo.gateway import GatewayToolError + +from .conftest import ( + FakeResponse, + call_result, + jsonrpc_result, + make_gateway, + sent_payload, +) + +_SEARCH_PAYLOAD = { + "results": [ + { + "index": 1, + "use_case": "search the web", + "results": [ + { + "name": "web_search", + "title": "Web Search", + "description": "Search the public web", + "inputSchema": {"type": "object"}, + "score": 12.3, + } + ], + } + ] +} + + +def _invoke_envelope(results): + return { + "total_count": len(results), + "success_count": sum( + 1 for r in results if r["result"].get("status") == "succeeded" + ), + "error_count": sum( + 1 for r in results if r["result"].get("status") != "succeeded" + ), + "results": results, + } + + +# -- search ------------------------------------------------------------------ + + +def test_search_accepts_single_string(): + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result(_SEARCH_PAYLOAD)))] + ) + result = gateway.tools.search("search the web") + + params = sent_payload(gateway)["params"] + assert params["name"] == "action.search" + assert params["arguments"]["queries"] == [{"use_case": "search the web"}] + assert result.results[0].results[0].name == "web_search" + + +def test_search_accepts_dicts_and_filters(): + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result(_SEARCH_PAYLOAD)))] + ) + gateway.tools.search( + [ + {"use_case": "find stuff", "known_fields": "site:example.com"}, + "another use case", + ], + providers=["exa"], + tags=["web"], + limit=3, + ) + arguments = sent_payload(gateway)["params"]["arguments"] + assert arguments["queries"] == [ + {"use_case": "find stuff", "known_fields": "site:example.com"}, + {"use_case": "another use case"}, + ] + assert arguments["providers"] == ["exa"] + assert arguments["tags"] == ["web"] + assert arguments["limit"] == 3 + + +def test_search_rejects_missing_use_case_and_bad_counts(): + gateway = make_gateway([]) + with pytest.raises(ValueError, match="use_case"): + gateway.tools.search([{"known_fields": "x"}]) + with pytest.raises(ValueError, match="between 1 and 5"): + gateway.tools.search(["a", "b", "c", "d", "e", "f"]) + with pytest.raises(TypeError): + gateway.tools.search([42]) + + +# -- invoke ------------------------------------------------------------------ + + +def test_invoke_shapes_arguments_and_returns_envelope(): + envelope = _invoke_envelope( + [ + { + "index": 0, + "tool": "web_search", + "result": {"status": "succeeded", "output": {"answer": 1}}, + }, + { + "index": 1, + "tool": "missing_tool", + "result": { + "status": "failed", + "error": {"class": "invalid_argument", "message": "unknown tool"}, + }, + }, + ] + ) + gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(envelope)))]) + result = gateway.tools.invoke( + [ + {"tool": "web_search", "arguments": {"query": "do"}}, + {"tool_slug": "missing_tool"}, + ], + rationale="testing", + ) + + params = sent_payload(gateway)["params"] + assert params["name"] == "action.invoke" + assert params["arguments"]["rationale"] == "testing" + assert params["arguments"]["tools"] == [ + {"tool": "web_search", "arguments": {"query": "do"}}, + {"tool": "missing_tool", "arguments": {}}, + ] + + # per-item failures stay in the envelope — no raise + assert result.error_count == 1 + assert result.results[1].result.status == "failed" + + +def test_invoke_validates_counts_and_entries(): + gateway = make_gateway([]) + with pytest.raises(ValueError, match="between 1 and 10"): + gateway.tools.invoke([]) + with pytest.raises(ValueError, match="between 1 and 10"): + gateway.tools.invoke([{"tool": f"t{i}", "arguments": {}} for i in range(11)]) + with pytest.raises(ValueError, match="'tool' name"): + gateway.tools.invoke([{"arguments": {}}]) + with pytest.raises(TypeError): + gateway.tools.invoke(["web_search"]) + + +def test_invoke_one_returns_output(): + envelope = _invoke_envelope( + [ + { + "index": 0, + "tool": "web_search", + "result": {"status": "succeeded", "output": {"answer": 42}}, + } + ] + ) + gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(envelope)))]) + output = gateway.tools.invoke_one("web_search", {"query": "do"}) + assert output.answer == 42 + + +def test_invoke_one_raises_on_failure(): + envelope = _invoke_envelope( + [ + { + "index": 0, + "tool": "web_search", + "result": { + "status": "failed", + "error": { + "class": "upstream_error", + "message": "exa is down", + "retriable": True, + }, + }, + "invocation_id": "inv_9", + } + ] + ) + gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(envelope)))]) + with pytest.raises(GatewayToolError, match="exa is down") as excinfo: + gateway.tools.invoke_one("web_search", {"query": "do"}) + assert excinfo.value.error_class == "upstream_error" + assert excinfo.value.retriable is True + + +# -- concrete call ----------------------------------------------------------- + + +def test_call_hits_concrete_endpoint(): + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result({"provider": "exa"})))] + ) + result = gateway.tools.call("web_search", {"query": "do"}) + payload = sent_payload(gateway) + assert payload["params"]["name"] == "web_search" + assert result.provider == "exa" diff --git a/tests/gateway/test_transport.py b/tests/gateway/test_transport.py new file mode 100644 index 00000000..b5e53469 --- /dev/null +++ b/tests/gateway/test_transport.py @@ -0,0 +1,190 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :mod:`pydo.gateway.transport` (MCP JSON-RPC wire layer).""" + +from __future__ import annotations + +import pytest +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceNotFoundError, +) + +from pydo.gateway import ( + MCP_PROTOCOL_VERSION, + GatewayProtocolError, + GatewayToolError, +) + +from .conftest import ( + FakeResponse, + call_result, + jsonrpc_error, + jsonrpc_result, + make_gateway, + sent_payload, + sent_request, +) + + +def test_list_tools_posts_jsonrpc_to_meta_endpoint(): + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result({"tools": [{"name": "action.search"}]}))] + ) + tools = gateway.tools.list() + + request = sent_request(gateway) + assert request.method == "POST" + assert request.url.endswith("/mcp/meta") + assert request.headers["Content-Type"] == "application/json" + assert request.headers["MCP-Protocol-Version"] == MCP_PROTOCOL_VERSION + assert request.headers["Accept"] == "application/json, text/event-stream" + + payload = sent_payload(gateway) + assert payload["jsonrpc"] == "2.0" + assert payload["method"] == "tools/list" + assert isinstance(payload["id"], int) + + assert tools[0].name == "action.search" + + +def test_list_tools_include_all_hits_concrete_endpoint(): + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result({"tools": [{"name": "web_search"}]}))] + ) + gateway.tools.list(include_all=True) + assert sent_request(gateway).url.endswith("/mcp") + + +def test_call_tool_prefers_structured_content(): + structured = {"stdout": "hi", "exit_code": 0} + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result(structured, text="hi")))] + ) + result = gateway.code.execute("print('hi')") + assert result.stdout == "hi" + assert result.exit_code == 0 + + +def test_call_tool_falls_back_to_content_text(): + gateway = make_gateway( + [ + FakeResponse( + 200, + jsonrpc_result( + {"isError": False, "content": [{"type": "text", "text": "plain"}]} + ), + ) + ] + ) + result = gateway.tools.call("web_fetch", {"url": "https://example.com"}) + assert result == "plain" + + +def test_call_tool_content_text_parsed_as_json_when_possible(): + gateway = make_gateway( + [ + FakeResponse( + 200, + jsonrpc_result( + { + "isError": False, + "content": [{"type": "text", "text": '{"answer": 42}'}], + } + ), + ) + ] + ) + result = gateway.tools.call("web_fetch", {"url": "https://example.com"}) + assert result.answer == 42 + + +def test_is_error_raises_gateway_tool_error_with_taxonomy(): + structured = { + "invocation_id": "inv_1", + "error": { + "class": "rate_limited", + "message": "slow down", + "retriable": True, + "recovery_hint": "backoff", + }, + } + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result(call_result(structured, is_error=True)))] + ) + with pytest.raises(GatewayToolError) as excinfo: + gateway.tools.call("web_search", {"query": "x"}) + err = excinfo.value + assert err.error_class == "rate_limited" + assert err.retriable is True + assert err.recovery_hint == "backoff" + assert err.invocation_id == "inv_1" + + +def test_is_error_without_structure_uses_content_text(): + gateway = make_gateway( + [ + FakeResponse( + 200, + jsonrpc_result( + {"isError": True, "content": [{"type": "text", "text": "boom"}]} + ), + ) + ] + ) + with pytest.raises(GatewayToolError, match="boom"): + gateway.tools.call("web_search", {"query": "x"}) + + +def test_jsonrpc_error_raises_protocol_error(): + gateway = make_gateway( + [FakeResponse(200, jsonrpc_error(-32601, "method not found"))] + ) + with pytest.raises(GatewayProtocolError) as excinfo: + gateway.tools.list() + assert excinfo.value.code == -32601 + + +def test_non_json_body_raises_protocol_error(): + gateway = make_gateway([FakeResponse(200, "nope")]) + with pytest.raises(GatewayProtocolError, match="non-JSON"): + gateway.tools.list() + + +@pytest.mark.parametrize( + "status,exc", + [ + (401, ClientAuthenticationError), + (404, ResourceNotFoundError), + (400, HttpResponseError), + (412, HttpResponseError), + ], +) +def test_http_errors_are_mapped(status, exc): + gateway = make_gateway([FakeResponse(status, {"type": "invalid_request"})]) + with pytest.raises(exc): + gateway.tools.list() + + +def test_412_message_mentions_release_gate(): + gateway = make_gateway([FakeResponse(412, "nope")]) + with pytest.raises(HttpResponseError, match="Action Infra release"): + gateway.tools.list() + + +def test_request_ids_increment(): + gateway = make_gateway( + [ + FakeResponse(200, jsonrpc_result({"tools": []}, rpc_id=1)), + FakeResponse(200, jsonrpc_result({"tools": []}, rpc_id=2)), + ] + ) + gateway.tools.list() + gateway.tools.list() + first = sent_payload(gateway, 0) + second = sent_payload(gateway, 1) + assert second["id"] == first["id"] + 1 From 89a2d1e59acd1ee23c91f619558ea1d36a366be3 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 8 Jul 2026 10:20:13 -0500 Subject: [PATCH 02/19] Add DO Action Gateway MCP server SDK functions --- examples/gateway/function_calling_loop.py | 2 +- examples/gateway/messages_tool_use.py | 35 ++++- src/pydo/aio/gateway/custom_operations.py | 24 ++- src/pydo/gateway/__init__.py | 7 +- src/pydo/gateway/custom_models.py | 6 +- src/pydo/gateway/custom_operations.py | 88 +++++++++-- src/pydo/gateway/providers.py | 64 +++++++- tests/gateway/test_async_gateway.py | 18 +-- tests/gateway/test_code.py | 2 +- tests/gateway/test_providers.py | 181 ++++++++++++++++++++-- tests/gateway/test_tools.py | 4 +- tests/gateway/test_transport.py | 4 +- 12 files changed, 373 insertions(+), 62 deletions(-) diff --git a/examples/gateway/function_calling_loop.py b/examples/gateway/function_calling_loop.py index 5c92cded..192cdccc 100644 --- a/examples/gateway/function_calling_loop.py +++ b/examples/gateway/function_calling_loop.py @@ -21,7 +21,7 @@ client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) -model = os.environ.get("MODEL", "llama3.3-70b-instruct") +model = os.environ.get("MODEL", "openai-gpt-4o") prompt = os.environ.get( "PROMPT", "Find the latest news about DigitalOcean and summarize it." ) diff --git a/examples/gateway/messages_tool_use.py b/examples/gateway/messages_tool_use.py index 5a4e2f3f..bbeb9ea6 100644 --- a/examples/gateway/messages_tool_use.py +++ b/examples/gateway/messages_tool_use.py @@ -18,12 +18,39 @@ from pydo import Client from pydo.gateway import MessagesProvider + +def _assistant_content(response) -> list: + """Return assistant content blocks, tolerating missing keys.""" + content = response.get("content") + return list(content) if content else [] + + +def _print_final_message(response) -> None: + """Print assistant text from a Messages API response.""" + if response.get("type") == "error": + error = response.get("error") or {} + raise RuntimeError(f"Messages API error: {error}") + + blocks = _assistant_content(response) + printed = False + for block in blocks: + if block.get("type") == "text" and block.get("text"): + print(block["text"]) + printed = True + + if not printed: + stop_reason = response.get("stop_reason") + if stop_reason: + print(f"(no text blocks; stop_reason={stop_reason!r})") + print(response) + + client = Client( token=os.environ["DIGITALOCEAN_TOKEN"], gateway_provider=MessagesProvider(), ) -model = os.environ.get("MODEL", "anthropic-claude-3.5-haiku") +model = os.environ.get("MODEL", "claude-opus-4-6") prompt = os.environ.get( "PROMPT", "Find the latest news about DigitalOcean and summarize it." ) @@ -42,10 +69,8 @@ if response.get("stop_reason") != "tool_use": break - messages.append({"role": "assistant", "content": list(response.content)}) + messages.append({"role": "assistant", "content": _assistant_content(response)}) # One user turn containing all tool_result blocks: messages.extend(client.gateway.handle_tool_calls(response)) -for block in response.content: - if block.get("type") == "text": - print(block.text) +_print_final_message(response) diff --git a/src/pydo/aio/gateway/custom_operations.py b/src/pydo/aio/gateway/custom_operations.py index f6c2d1fb..f62e88ce 100644 --- a/src/pydo/aio/gateway/custom_operations.py +++ b/src/pydo/aio/gateway/custom_operations.py @@ -22,11 +22,13 @@ from pydo.gateway.custom_operations import ( QueryInput, ToolSpecInput, + _normalize_invoke_entry, _normalize_queries, _normalize_tool_specs, _result_output_or_raise, _flatten_search_results, _tool_name, + normalize_invoke_arguments, ) from pydo.gateway.providers import _error_payload, _get from pydo.gateway.transport import ( @@ -229,18 +231,30 @@ async def async_execute_tool_calls( for index, call in enumerate(calls): if call.name in META_TOOL_NAMES: try: + arguments = call.arguments + if call.name == META_INVOKE: + arguments = normalize_invoke_arguments(arguments) results[index] = await tools_operations._transport.call_tool( - call.name, call.arguments, meta=True + call.name, arguments, meta=True ) - except GatewayToolError as exc: + except (GatewayToolError, TypeError, ValueError) as exc: results[index] = _error_payload(exc) else: concrete.append(index) if concrete: - batch = [ - {"tool": calls[i].name, "arguments": calls[i].arguments} for i in concrete - ] + try: + batch = [ + _normalize_invoke_entry( + {"tool": calls[i].name, "arguments": calls[i].arguments} + ) + for i in concrete + ] + except (TypeError, ValueError) as exc: + error = _error_payload(exc) + for index in concrete: + results[index] = error + return results envelope = await tools_operations.invoke(batch, rationale=rationale) items = (_get(envelope, "results") or []) if envelope is not None else [] for position, index in enumerate(concrete): diff --git a/src/pydo/gateway/__init__.py b/src/pydo/gateway/__init__.py index cd7b4605..3719d5f8 100644 --- a/src/pydo/gateway/__init__.py +++ b/src/pydo/gateway/__init__.py @@ -29,7 +29,7 @@ ToolErrorClass, ToolResultStatus, ) -from .custom_operations import CodeOperations, ToolsOperations +from .custom_operations import CodeOperations, ToolsOperations, normalize_invoke_arguments from .providers import ( BaseProvider, ChatCompletionsProvider, @@ -37,6 +37,8 @@ ResponsesProvider, default_provider, execute_tool_calls, + simplify_inference_tool_schema, + simplify_messages_input_schema, ) from .transport import MCP_PROTOCOL_VERSION, GatewayTransport, MCPTransport @@ -113,6 +115,7 @@ def execute_tool_calls( "GatewayResources", "ToolsOperations", "CodeOperations", + "normalize_invoke_arguments", "GatewayTransport", "MCPTransport", "MCP_PROTOCOL_VERSION", @@ -122,6 +125,8 @@ def execute_tool_calls( "ResponsesProvider", "default_provider", "execute_tool_calls", + "simplify_inference_tool_schema", + "simplify_messages_input_schema", "ToolCall", "GatewayToolError", "GatewayProtocolError", diff --git a/src/pydo/gateway/custom_models.py b/src/pydo/gateway/custom_models.py index 7598f39a..9ff64e2f 100644 --- a/src/pydo/gateway/custom_models.py +++ b/src/pydo/gateway/custom_models.py @@ -9,9 +9,9 @@ from typing import Any, Dict, List, Optional # Meta-tool names exposed on the gateway's ``/mcp/meta`` endpoint. -META_SEARCH = "action.search" -META_INVOKE = "action.invoke" -META_CODE = "action.code" +META_SEARCH = "action_search" +META_INVOKE = "action_invoke" +META_CODE = "action_code" META_TOOL_NAMES = frozenset({META_SEARCH, META_INVOKE, META_CODE}) diff --git a/src/pydo/gateway/custom_operations.py b/src/pydo/gateway/custom_operations.py index dc89a249..73278dda 100644 --- a/src/pydo/gateway/custom_operations.py +++ b/src/pydo/gateway/custom_operations.py @@ -11,6 +11,7 @@ from __future__ import annotations +import json as _json from typing import Any, Dict, List, Optional, Sequence, Union from .custom_models import ( @@ -53,19 +54,83 @@ def _normalize_queries( return normalized +def _decode_json_object(value: Any) -> Dict[str, Any]: + if isinstance(value, str): + if not value.strip(): + return {} + return _json.loads(value) + if isinstance(value, dict): + return dict(value) + return {} + + +_INVOKE_ENTRY_RESERVED_KEYS = frozenset( + {"tool", "tool_slug", "name", "function", "type", "id"} +) + + +def _normalize_invoke_entry(spec: Any) -> Dict[str, Any]: + """Normalize one ``action.invoke`` tool entry to ``{tool, arguments}``. + + Models often emit chat-style ``{"function": {"name", "arguments"}}`` blobs + inside ``action.invoke`` even though the gateway expects ``tool`` / + ``tool_slug``. This helper accepts both shapes (plus a flat ``name`` key + and hoisted argument fields). + """ + if not isinstance(spec, dict): + raise TypeError( + "each invoke entry must be a dict like " + "{'tool': name, 'arguments': {...}}" + ) + + function = spec.get("function") + if isinstance(function, dict): + name = ( + function.get("name") + or spec.get("tool") + or spec.get("tool_slug") + or spec.get("name") + ) + if not name: + raise ValueError("each invoke entry requires a tool name") + if function.get("arguments") is not None: + arguments = _decode_json_object(function.get("arguments")) + else: + arguments = _decode_json_object(spec.get("arguments")) + return {"tool": name, "arguments": arguments} + + name = spec.get("tool") or spec.get("tool_slug") or spec.get("name") + if not name: + raise ValueError("each invoke entry requires a 'tool' name") + + arguments = spec.get("arguments") + if arguments is None: + hoisted = {k: v for k, v in spec.items() if k not in _INVOKE_ENTRY_RESERVED_KEYS} + arguments = hoisted if hoisted else {} + else: + arguments = _decode_json_object(arguments) + return {"tool": name, "arguments": arguments} + + +def normalize_invoke_arguments(arguments: Any) -> Dict[str, Any]: + """Normalize an ``action.invoke`` arguments object before calling the gateway.""" + if not isinstance(arguments, dict): + return {"tools": []} + normalized = dict(arguments) + tools = normalized.get("tools") + if tools is None: + return normalized + if isinstance(tools, dict): + tools = [tools] + elif not isinstance(tools, list): + tools = [tools] + normalized["tools"] = [_normalize_invoke_entry(entry) for entry in tools] + return normalized + + def _normalize_tool_specs(tools: Sequence[ToolSpecInput]) -> List[Dict[str, Any]]: """Normalize invoke entries; ``tool_slug`` is accepted as alias for ``tool``.""" - normalized: List[Dict[str, Any]] = [] - for spec in tools: - if not isinstance(spec, dict): - raise TypeError( - "each invoke entry must be a dict like " - "{'tool': name, 'arguments': {...}}" - ) - name = spec.get("tool") or spec.get("tool_slug") - if not name: - raise ValueError("each invoke entry requires a 'tool' name") - normalized.append({"tool": name, "arguments": spec.get("arguments") or {}}) + normalized = [_normalize_invoke_entry(spec) for spec in tools] if not 1 <= len(normalized) <= _MAX_INVOKE_TOOLS: raise ValueError(f"invoke accepts between 1 and {_MAX_INVOKE_TOOLS} tools") return normalized @@ -284,4 +349,5 @@ def execute(self, code: str, *, thought: Optional[str] = None) -> Any: __all__ = [ "ToolsOperations", "CodeOperations", + "normalize_invoke_arguments", ] diff --git a/src/pydo/gateway/providers.py b/src/pydo/gateway/providers.py index 6a0b1c8d..b18dc1a9 100644 --- a/src/pydo/gateway/providers.py +++ b/src/pydo/gateway/providers.py @@ -25,10 +25,44 @@ from __future__ import annotations +import copy import json as _json from typing import Any, Dict, List, Optional, Sequence from .custom_models import ToolCall +from .custom_operations import _normalize_invoke_entry, normalize_invoke_arguments + + +def simplify_inference_tool_schema(schema: Any) -> Dict[str, Any]: + """Normalize a gateway tool JSON Schema for inference ``tools=`` parameters. + + DO inference endpoints (chat completions, messages, responses) require a + plain top-level ``object`` schema. Gateway meta-tools such as + ``action.code`` use top-level combinators (``anyOf`` for Composio alias + args). We drop those keywords and keep ``properties``; the gateway still + validates aliases server-side. + """ + if not isinstance(schema, dict): + return {"type": "object", "properties": {}} + simplified = copy.deepcopy(schema) + for key in ( + "oneOf", + "allOf", + "anyOf", + "enum", + "const", + "not", + ): + simplified.pop(key, None) + if "type" not in simplified: + simplified["type"] = "object" + if simplified.get("type") == "object" and "properties" not in simplified: + simplified["properties"] = {} + return simplified + + +# Backward-compatible alias. +simplify_messages_input_schema = simplify_inference_tool_schema def _get(obj: Any, key: str, default: Any = None) -> Any: @@ -54,7 +88,9 @@ def _tool_fields(tool: Any) -> Dict[str, Any]: return { "name": _get(tool, "name"), "description": _get(tool, "description") or _get(tool, "title") or "", - "parameters": dict(_get(tool, "inputSchema") or {"type": "object"}), + "parameters": simplify_inference_tool_schema( + _get(tool, "inputSchema") or {"type": "object"} + ), } @@ -238,7 +274,7 @@ def execute_tool_calls( ``action.invoke``. Per-tool failures become structured error payloads rather than raising, so the model can observe and recover. """ - from .custom_models import META_TOOL_NAMES, GatewayToolError + from .custom_models import META_INVOKE, META_TOOL_NAMES, GatewayToolError results: List[Any] = [None] * len(calls) concrete: List[int] = [] @@ -246,18 +282,30 @@ def execute_tool_calls( for index, call in enumerate(calls): if call.name in META_TOOL_NAMES: try: + arguments = call.arguments + if call.name == META_INVOKE: + arguments = normalize_invoke_arguments(arguments) results[index] = tools_operations._transport.call_tool( - call.name, call.arguments, meta=True + call.name, arguments, meta=True ) - except GatewayToolError as exc: + except (GatewayToolError, TypeError, ValueError, _json.JSONDecodeError) as exc: results[index] = _error_payload(exc) else: concrete.append(index) if concrete: - batch = [ - {"tool": calls[i].name, "arguments": calls[i].arguments} for i in concrete - ] + try: + batch = [ + _normalize_invoke_entry( + {"tool": calls[i].name, "arguments": calls[i].arguments} + ) + for i in concrete + ] + except (TypeError, ValueError, _json.JSONDecodeError) as exc: + error = _error_payload(exc) + for index in concrete: + results[index] = error + return results envelope = tools_operations.invoke(batch, rationale=rationale) items = (_get(envelope, "results") or []) if envelope is not None else [] for position, index in enumerate(concrete): @@ -297,4 +345,6 @@ def _error_payload(exc: Any) -> Dict[str, Any]: "ResponsesProvider", "default_provider", "execute_tool_calls", + "simplify_inference_tool_schema", + "simplify_messages_input_schema", ] diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index a180011f..78d60373 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -36,10 +36,10 @@ def _sent_payload(gateway, index=0): def test_list_defaults_to_meta(): gateway = make_async_gateway( - [AsyncFakeResponse(200, jsonrpc_result({"tools": [{"name": "action.search"}]}))] + [AsyncFakeResponse(200, jsonrpc_result({"tools": [{"name": "action_search"}]}))] ) tools = _run(gateway.tools.list()) - assert tools[0].name == "action.search" + assert tools[0].name == "action_search" pipeline = gateway._transport._client._original._pipeline assert pipeline.calls[0].request.url.endswith("/mcp/meta") @@ -63,7 +63,7 @@ def test_invoke_and_invoke_one(): output = _run(gateway.tools.invoke_one("web_search", {"query": "do"})) assert output.answer == 7 params = _sent_payload(gateway)["params"] - assert params["name"] == "action.invoke" + assert params["name"] == "action_invoke" def test_code_execute_failure_raises(): @@ -78,16 +78,16 @@ def test_code_execute_failure_raises(): def test_tools_callable_and_handle_tool_calls(): meta_tools = [ { - "name": "action.search", + "name": "action_search", "description": "d", "inputSchema": {"type": "object"}, }, { - "name": "action.invoke", + "name": "action_invoke", "description": "d", "inputSchema": {"type": "object"}, }, - {"name": "action.code", "description": "d", "inputSchema": {"type": "object"}}, + {"name": "action_code", "description": "d", "inputSchema": {"type": "object"}}, ] envelope = { "total_count": 1, @@ -133,9 +133,9 @@ async def scenario(): tools, messages = _run(scenario()) assert [t["function"]["name"] for t in tools] == [ - "action.search", - "action.invoke", - "action.code", + "action_search", + "action_invoke", + "action_code", ] assert messages[0]["role"] == "tool" assert json.loads(messages[0]["content"]) == {"ok": True} diff --git a/tests/gateway/test_code.py b/tests/gateway/test_code.py index 36d4e50d..c1d8640f 100644 --- a/tests/gateway/test_code.py +++ b/tests/gateway/test_code.py @@ -29,7 +29,7 @@ def test_execute_happy_path(): request = sent_request(gateway) assert request.url.endswith("/mcp/meta") params = sent_payload(gateway)["params"] - assert params["name"] == "action.code" + assert params["name"] == "action_code" assert params["arguments"] == { "code": "print('hello')", "thought": "say hello", diff --git a/tests/gateway/test_providers.py b/tests/gateway/test_providers.py index 22ef8f18..633fa8d1 100644 --- a/tests/gateway/test_providers.py +++ b/tests/gateway/test_providers.py @@ -16,6 +16,8 @@ ChatCompletionsProvider, MessagesProvider, ResponsesProvider, + normalize_invoke_arguments, + simplify_messages_input_schema, ) from .conftest import ( @@ -41,17 +43,17 @@ _META_TOOLS = [ { - "name": "action.search", + "name": "action_search", "description": "Find tools", "inputSchema": {"type": "object"}, }, { - "name": "action.invoke", + "name": "action_invoke", "description": "Run tools", "inputSchema": {"type": "object"}, }, { - "name": "action.code", + "name": "action_code", "description": "Run code", "inputSchema": {"type": "object"}, }, @@ -86,6 +88,59 @@ def test_messages_wrap_tools(): ] +def test_messages_wrap_tools_preserves_meta_tool_names(): + tools = MessagesProvider().wrap_tools(_META_TOOLS) + assert [tool["name"] for tool in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + + +def test_simplify_messages_input_schema_strips_top_level_any_of(): + schema = { + "type": "object", + "properties": { + "code": {"type": "string"}, + "code_to_execute": {"type": "string"}, + }, + "anyOf": [ + {"required": ["code"]}, + {"required": ["code_to_execute"]}, + ], + } + simplified = simplify_messages_input_schema(schema) + assert "anyOf" not in simplified + assert simplified["properties"]["code"]["type"] == "string" + + +def test_chat_completions_wrap_tools_strips_any_of_from_code_meta_tool(): + tools = ChatCompletionsProvider().wrap_tools( + [ + { + "name": "action_code", + "description": "Run Python", + "inputSchema": { + "type": "object", + "properties": {"code": {"type": "string"}}, + "anyOf": [{"required": ["code"]}], + }, + } + ] + ) + assert tools[0]["function"]["name"] == "action_code" + assert "anyOf" not in tools[0]["function"]["parameters"] + + +def test_responses_wrap_tools_preserves_meta_tool_names(): + tools = ResponsesProvider().wrap_tools(_META_TOOLS) + assert [tool["name"] for tool in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + + def test_responses_wrap_tools(): tools = ResponsesProvider().wrap_tools(_CATALOG) assert tools == [ @@ -102,7 +157,7 @@ def test_wrap_tools_falls_back_to_title_and_empty_schema(): tools = ChatCompletionsProvider().wrap_tools([{"name": "t", "title": "T"}]) function = tools[0]["function"] assert function["description"] == "T" - assert function["parameters"] == {"type": "object"} + assert function["parameters"] == {"type": "object", "properties": {}} # -- extract_tool_calls ------------------------------------------------------- @@ -233,9 +288,22 @@ def test_tools_callable_wraps_meta_tools_by_default(): ) tools = gateway.tools() assert [t["function"]["name"] for t in tools] == [ - "action.search", - "action.invoke", - "action.code", + "action_search", + "action_invoke", + "action_code", + ] + + +def test_tools_callable_wraps_meta_tools_for_messages(): + gateway = make_gateway( + [FakeResponse(200, jsonrpc_result({"tools": _META_TOOLS}))], + provider=MessagesProvider(), + ) + tools = gateway.tools() + assert [t["name"] for t in tools] == [ + "action_search", + "action_invoke", + "action_code", ] @@ -304,7 +372,7 @@ def test_handle_tool_calls_batches_concrete_tools(): messages = gateway.handle_tool_calls(_chat_response(), rationale="why not") params = sent_payload(gateway)["params"] - assert params["name"] == "action.invoke" + assert params["name"] == "action_invoke" assert params["arguments"]["rationale"] == "why not" assert params["arguments"]["tools"] == [ {"tool": "web_search", "arguments": {"query": "do"}} @@ -315,17 +383,69 @@ def test_handle_tool_calls_batches_concrete_tools(): assert json.loads(messages[0]["content"]) == {"answer": 42} -def test_handle_tool_calls_routes_meta_tools_directly(): +def test_normalize_invoke_arguments_accepts_chat_function_shape(): + arguments = normalize_invoke_arguments( + { + "tools": [ + { + "type": "function", + "function": { + "name": "web_search", + "arguments": '{"query": "digitalocean news"}', + }, + } + ] + } + ) + assert arguments["tools"] == [ + {"tool": "web_search", "arguments": {"query": "digitalocean news"}} + ] + + +def test_handle_tool_calls_normalizes_action_invoke_payload(): + envelope = { + "total_count": 1, + "success_count": 1, + "error_count": 0, + "results": [ + { + "index": 0, + "tool": "web_search", + "result": {"status": "succeeded", "output": {"answer": 1}}, + } + ], + } + gateway = make_gateway( + [ + FakeResponse(200, jsonrpc_result({"tools": _META_TOOLS})), + FakeResponse(200, jsonrpc_result(call_result(envelope))), + ], + provider=ChatCompletionsProvider(), + ) + gateway.tools() response = { "choices": [ { "message": { "tool_calls": [ { - "id": "call_meta", + "id": "call_invoke", "function": { - "name": "action.search", - "arguments": '{"queries": [{"use_case": "x"}]}', + "name": "action_invoke", + "arguments": json.dumps( + { + "tools": [ + { + "function": { + "name": "web_search", + "arguments": { + "query": "digitalocean" + }, + } + } + ] + } + ), }, } ] @@ -333,13 +453,44 @@ def test_handle_tool_calls_routes_meta_tools_directly(): } ] } + messages = gateway.handle_tool_calls(response) + params = sent_payload(gateway, 1)["params"] + assert params["name"] == "action_invoke" + assert params["arguments"]["tools"] == [ + {"tool": "web_search", "arguments": {"query": "digitalocean"}} + ] + assert json.loads(messages[0]["content"]) == envelope + + +def test_handle_tool_calls_routes_meta_tools_directly(): gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result({"results": []})))], + [ + FakeResponse(200, jsonrpc_result({"tools": _META_TOOLS})), + FakeResponse(200, jsonrpc_result(call_result({"results": []}))), + ], provider=ChatCompletionsProvider(), ) + gateway.tools() + response = { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "call_meta", + "function": { + "name": "action_search", + "arguments": '{"queries": [{"use_case": "x"}]}', + }, + } + ] + } + } + ] + } messages = gateway.handle_tool_calls(response) - params = sent_payload(gateway)["params"] - assert params["name"] == "action.search" + params = sent_payload(gateway, 1)["params"] + assert params["name"] == "action_search" assert json.loads(messages[0]["content"]) == {"results": []} diff --git a/tests/gateway/test_tools.py b/tests/gateway/test_tools.py index 86b364a2..d23b2034 100644 --- a/tests/gateway/test_tools.py +++ b/tests/gateway/test_tools.py @@ -61,7 +61,7 @@ def test_search_accepts_single_string(): result = gateway.tools.search("search the web") params = sent_payload(gateway)["params"] - assert params["name"] == "action.search" + assert params["name"] == "action_search" assert params["arguments"]["queries"] == [{"use_case": "search the web"}] assert result.results[0].results[0].name == "web_search" @@ -130,7 +130,7 @@ def test_invoke_shapes_arguments_and_returns_envelope(): ) params = sent_payload(gateway)["params"] - assert params["name"] == "action.invoke" + assert params["name"] == "action_invoke" assert params["arguments"]["rationale"] == "testing" assert params["arguments"]["tools"] == [ {"tool": "web_search", "arguments": {"query": "do"}}, diff --git a/tests/gateway/test_transport.py b/tests/gateway/test_transport.py index b5e53469..d6003887 100644 --- a/tests/gateway/test_transport.py +++ b/tests/gateway/test_transport.py @@ -33,7 +33,7 @@ def test_list_tools_posts_jsonrpc_to_meta_endpoint(): gateway = make_gateway( - [FakeResponse(200, jsonrpc_result({"tools": [{"name": "action.search"}]}))] + [FakeResponse(200, jsonrpc_result({"tools": [{"name": "action_search"}]}))] ) tools = gateway.tools.list() @@ -49,7 +49,7 @@ def test_list_tools_posts_jsonrpc_to_meta_endpoint(): assert payload["method"] == "tools/list" assert isinstance(payload["id"], int) - assert tools[0].name == "action.search" + assert tools[0].name == "action_search" def test_list_tools_include_all_hits_concrete_endpoint(): From 3a853e6324bd446797ac2170601dbcc678ef71fe Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 8 Jul 2026 10:41:50 -0500 Subject: [PATCH 03/19] Update acton gateway tests --- tests/gateway/conftest.py | 36 +++++++++++++++++-- tests/gateway/test_async_gateway.py | 27 ++------------ tests/gateway/test_providers.py | 56 ++++++----------------------- tests/gateway/test_tools.py | 41 ++++++--------------- 4 files changed, 57 insertions(+), 103 deletions(-) diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index ab337271..827375ea 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -1,4 +1,4 @@ -# pylint: disable=missing-function-docstring,protected-access +# pylint: disable=missing-function-docstring,protected-access,missing-class-docstring,too-few-public-methods # ------------------------------------ # Copyright (c) DigitalOcean. # Licensed under the Apache-2.0 License. @@ -44,7 +44,7 @@ def close(self) -> None: class AsyncFakeResponse(FakeResponse): - async def read(self) -> bytes: # type: ignore[override] + async def read(self) -> bytes: # pylint: disable=invalid-overridden-method return self._body_bytes @@ -91,6 +91,38 @@ def call_result( return result +def invoke_envelope( + results: List[dict] | None = None, + *, + tool: str = "web_search", + output: Any = None, + error: Any = None, + invocation_id: str | None = None, +) -> dict: + """Build a gateway ``action_invoke`` result envelope for tests.""" + if results is None: + if error is not None: + result_body: dict = {"status": "failed", "error": error} + else: + if output is None: + output = {"answer": 1} + result_body = {"status": "succeeded", "output": output} + entry: dict = {"index": 0, "tool": tool, "result": result_body} + if invocation_id is not None: + entry["invocation_id"] = invocation_id + results = [entry] + return { + "total_count": len(results), + "success_count": sum( + 1 for item in results if item["result"].get("status") == "succeeded" + ), + "error_count": sum( + 1 for item in results if item["result"].get("status") != "succeeded" + ), + "results": results, + } + + def make_gateway(responses: List[FakeResponse], provider=None) -> GatewayResources: parent = MagicMock() parent._client = MagicMock() diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index 78d60373..431ba974 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -17,6 +17,7 @@ from .conftest import ( AsyncFakeResponse, call_result, + invoke_envelope, jsonrpc_result, make_async_gateway, ) @@ -45,18 +46,7 @@ def test_list_defaults_to_meta(): def test_invoke_and_invoke_one(): - envelope = { - "total_count": 1, - "success_count": 1, - "error_count": 0, - "results": [ - { - "index": 0, - "tool": "web_search", - "result": {"status": "succeeded", "output": {"answer": 7}}, - } - ], - } + envelope = invoke_envelope(output={"answer": 7}) gateway = make_async_gateway( [AsyncFakeResponse(200, jsonrpc_result(call_result(envelope)))] ) @@ -89,18 +79,7 @@ def test_tools_callable_and_handle_tool_calls(): }, {"name": "action_code", "description": "d", "inputSchema": {"type": "object"}}, ] - envelope = { - "total_count": 1, - "success_count": 1, - "error_count": 0, - "results": [ - { - "index": 0, - "tool": "web_search", - "result": {"status": "succeeded", "output": {"ok": True}}, - } - ], - } + envelope = invoke_envelope(output={"ok": True}) gateway = make_async_gateway( [ AsyncFakeResponse(200, jsonrpc_result({"tools": meta_tools})), diff --git a/tests/gateway/test_providers.py b/tests/gateway/test_providers.py index 633fa8d1..b6413072 100644 --- a/tests/gateway/test_providers.py +++ b/tests/gateway/test_providers.py @@ -23,6 +23,7 @@ from .conftest import ( FakeResponse, call_result, + invoke_envelope, jsonrpc_result, make_gateway, sent_payload, @@ -238,14 +239,11 @@ def test_responses_extract(wrap): def test_extract_returns_empty_without_tool_calls(): - assert ( - ChatCompletionsProvider().extract_tool_calls( - {"choices": [{"message": {"content": "hi"}}]} - ) - == [] + assert not ChatCompletionsProvider().extract_tool_calls( + {"choices": [{"message": {"content": "hi"}}]} ) - assert MessagesProvider().extract_tool_calls({"content": []}) == [] - assert ResponsesProvider().extract_tool_calls({"output": []}) == [] + assert not MessagesProvider().extract_tool_calls({"content": []}) + assert not ResponsesProvider().extract_tool_calls({"output": []}) # -- format_tool_results ------------------------------------------------------ @@ -353,18 +351,7 @@ def test_tools_callable_via_search(): def test_handle_tool_calls_batches_concrete_tools(): - envelope = { - "total_count": 1, - "success_count": 1, - "error_count": 0, - "results": [ - { - "index": 0, - "tool": "web_search", - "result": {"status": "succeeded", "output": {"answer": 42}}, - } - ], - } + envelope = invoke_envelope(output={"answer": 42}) gateway = make_gateway( [FakeResponse(200, jsonrpc_result(call_result(envelope)))], provider=ChatCompletionsProvider(), @@ -403,18 +390,7 @@ def test_normalize_invoke_arguments_accepts_chat_function_shape(): def test_handle_tool_calls_normalizes_action_invoke_payload(): - envelope = { - "total_count": 1, - "success_count": 1, - "error_count": 0, - "results": [ - { - "index": 0, - "tool": "web_search", - "result": {"status": "succeeded", "output": {"answer": 1}}, - } - ], - } + envelope = invoke_envelope(output={"answer": 1}) gateway = make_gateway( [ FakeResponse(200, jsonrpc_result({"tools": _META_TOOLS})), @@ -495,21 +471,9 @@ def test_handle_tool_calls_routes_meta_tools_directly(): def test_handle_tool_calls_surfaces_failures_as_content(): - envelope = { - "total_count": 1, - "success_count": 0, - "error_count": 1, - "results": [ - { - "index": 0, - "tool": "web_search", - "result": { - "status": "failed", - "error": {"class": "timeout", "message": "too slow"}, - }, - } - ], - } + envelope = invoke_envelope( + error={"class": "timeout", "message": "too slow"}, + ) gateway = make_gateway( [FakeResponse(200, jsonrpc_result(call_result(envelope)))], provider=ChatCompletionsProvider(), diff --git a/tests/gateway/test_tools.py b/tests/gateway/test_tools.py index d23b2034..662b0121 100644 --- a/tests/gateway/test_tools.py +++ b/tests/gateway/test_tools.py @@ -14,6 +14,7 @@ from .conftest import ( FakeResponse, call_result, + invoke_envelope, jsonrpc_result, make_gateway, sent_payload, @@ -38,19 +39,6 @@ } -def _invoke_envelope(results): - return { - "total_count": len(results), - "success_count": sum( - 1 for r in results if r["result"].get("status") == "succeeded" - ), - "error_count": sum( - 1 for r in results if r["result"].get("status") != "succeeded" - ), - "results": results, - } - - # -- search ------------------------------------------------------------------ @@ -103,7 +91,7 @@ def test_search_rejects_missing_use_case_and_bad_counts(): def test_invoke_shapes_arguments_and_returns_envelope(): - envelope = _invoke_envelope( + envelope = invoke_envelope( [ { "index": 0, @@ -155,7 +143,7 @@ def test_invoke_validates_counts_and_entries(): def test_invoke_one_returns_output(): - envelope = _invoke_envelope( + envelope = invoke_envelope( [ { "index": 0, @@ -170,22 +158,13 @@ def test_invoke_one_returns_output(): def test_invoke_one_raises_on_failure(): - envelope = _invoke_envelope( - [ - { - "index": 0, - "tool": "web_search", - "result": { - "status": "failed", - "error": { - "class": "upstream_error", - "message": "exa is down", - "retriable": True, - }, - }, - "invocation_id": "inv_9", - } - ] + envelope = invoke_envelope( + error={ + "class": "upstream_error", + "message": "exa is down", + "retriable": True, + }, + invocation_id="inv_9", ) gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(envelope)))]) with pytest.raises(GatewayToolError, match="exa is down") as excinfo: From 301a2029e3aceaf9a13de94f19599e8eef0a7da4 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Mon, 20 Jul 2026 11:15:35 -0700 Subject: [PATCH 04/19] Fix action gateway session URLs --- Makefile | 1 + docs/gateway-sdk-experience.md | 157 ++++++++++++ examples/gateway/async_invoke_tools.py | 15 +- examples/gateway/execute_code.py | 10 +- examples/gateway/function_calling_loop.py | 20 +- examples/gateway/invoke_tools.py | 17 +- examples/gateway/list_tools.py | 20 +- examples/gateway/messages_tool_use.py | 19 +- examples/gateway/search_tools.py | 10 +- src/pydo/action_gateway/__init__.py | 138 ++++++++++ src/pydo/action_gateway/aio/__init__.py | 113 +++++++++ src/pydo/aio/gateway/__init__.py | 53 ++-- src/pydo/aio/gateway/custom_operations.py | 93 ++++++- src/pydo/aio/gateway/session.py | 196 +++++++++++++++ src/pydo/gateway/__init__.py | 103 +++++--- src/pydo/gateway/session.py | 240 ++++++++++++++++++ src/pydo/gateway/transport.py | 264 ++++++++++++++++++-- tests/gateway/conftest.py | 114 ++++++++- tests/gateway/test_action_gateway_client.py | 123 +++++++++ tests/gateway/test_async_gateway.py | 82 +++--- tests/gateway/test_code.py | 38 +-- tests/gateway/test_providers.py | 76 ++---- tests/gateway/test_session.py | 129 ++++++++++ tests/gateway/test_tools.py | 47 ++-- tests/gateway/test_transport.py | 229 ++++++++++------- 25 files changed, 1910 insertions(+), 397 deletions(-) create mode 100644 docs/gateway-sdk-experience.md create mode 100644 src/pydo/action_gateway/__init__.py create mode 100644 src/pydo/action_gateway/aio/__init__.py create mode 100644 src/pydo/aio/gateway/session.py create mode 100644 src/pydo/gateway/session.py create mode 100644 tests/gateway/test_action_gateway_client.py create mode 100644 tests/gateway/test_session.py diff --git a/Makefile b/Makefile index 73aaf2d3..15b16eb7 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ clean: ## Removes all generated code (except _patch.py files) @find src/pydo -type f \ ! -name "_patch.py" ! -name "custom_*.py" ! -name "exceptions.py" \ ! -path "*/gateway/*" \ + ! -path "*/action_gateway/*" \ -exec rm -rf {} + .PHONY: download-spec diff --git a/docs/gateway-sdk-experience.md b/docs/gateway-sdk-experience.md new file mode 100644 index 00000000..9813b9e4 --- /dev/null +++ b/docs/gateway-sdk-experience.md @@ -0,0 +1,157 @@ +# Action Gateway — Python SDK Experience + +**Audience:** internal alignment on the developer experience of the Action Gateway surface in `pydo`. +**Status:** proposal / preview. Feedback welcome — nothing here is final. + +The Action Gateway gives models access to a large catalog of third-party tools plus a sandboxed Python runtime. Usage is **session-first**: create a session on the DigitalOcean API, then call tools over REST on `actions.do-ai.run` with that session. + +--- + +## 1. Setup + +```bash +pip install pydo +export DIGITALOCEAN_TOKEN=... +``` + +```python +from pydo.action_gateway import Client + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + +session = client.sessions.create( + end_user_id="user-123", # required + # permissions optional — defaults to allow-all +) +``` + +`end_user_id` is required. If you omit `permissions`, the SDK creates a default policy of `{"defaultAction": "allow", "rules": []}`. Optional permissions: + +```python +session = client.sessions.create( + end_user_id="user-123", + permissions={ + "default_action": "ask", + "rules": [ + {"toolbelt": "read-only@1.2.3", "action": "allow"}, + {"tool": "gmail", "action": "allow"}, + ], + }, +) +``` + +Session create hits `POST /v2/sessions` on `api.digitalocean.com`. Tool calls go to the gateway host (`https://actions.do-ai.run` by default; override with `gateway_endpoint=` or `PYDO_GATEWAY_ENDPOINT`). + +`session.url` is the session-pinned MCP URL for external MCP clients: + +```text +https://actions.do-ai.run/mcp/session/ +``` + +--- + +## 2. Basic usage (no model involved) + +```python +results = session.tools.search("search the web for recent news") +catalog = session.tools.list(include_all=True) + +output = session.tools.invoke_one( + "EXA_SEARCH", + {"query": "DigitalOcean news", "num_results": 5}, +) + +envelope = session.tools.invoke([ + {"tool": "EXA_SEARCH", "arguments": {"query": "DigitalOcean news"}}, + {"tool": "HACKERNEWS_GET_TODAY_STORIES", "arguments": {}}, +]) + +result = session.code.execute("print(sum(range(10)))") +``` + +These map to REST: `POST /tools/search`, `POST /tools/invoke`, `POST /code/execute`, always with `X-Session-Id`. + +--- + +## 3. Using tools with a model + +- **`session.tools()`** — provider-formatted tool definitions for `tools=` +- **`session.handle_tool_calls(response)`** — execute the model's tool calls and return ready-to-append messages + +### Chat Completions + +```python +from pydo.action_gateway import Client + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.sessions.create(end_user_id="user-123") + +tools = session.tools() +messages = [{"role": "user", "content": + "Find the latest news about DigitalOcean and summarize it."}] + +while True: + response = client.chat.completions.create( + model="openai-gpt-4o", + messages=messages, + tools=tools, + ) + message = response.choices[0].message + if not message.get("tool_calls"): + break + + messages.append(dict(message)) + messages.extend(session.handle_tool_calls(response)) + +print(message["content"]) +``` + +### Messages API + +```python +from pydo.action_gateway import Client, MessagesProvider + +client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + gateway_provider=MessagesProvider(), +) +session = client.sessions.create(end_user_id="user-123") + +tools = session.tools() +# ... same loop with client.messages.create and session.handle_tool_calls +``` + +--- + +## 4. Meta-tools vs. concrete tools + +`session.tools()` defaults to the three meta-tools (`action_search`, `action_invoke`, `action_code`). For a fixed surface: + +```python +tools = session.tools(include_all=True) +tools = session.tools(names=["EXA_SEARCH"]) +tools = session.tools(search="post a message to slack", limit=5) +``` + +--- + +## 5. Async + +```python +from pydo.action_gateway.aio import Client + +async with Client(token=token) as client: + session = await client.sessions.create(end_user_id="user-123") + tools = await session.tools() + response = await client.chat.completions.create(..., tools=tools) + messages.extend(await session.handle_tool_calls(response)) +``` + +--- + +## 6. Design notes + +- **Session-first.** Bare gateway calls without a session are unsupported. +- **REST for SDK execution.** MCP remains available via `session.url` for external clients. +- **Provider pattern.** Chat Completions / Messages / Responses formatting stays in small provider classes. +- **Same DO token** for session create (public API) and gateway REST (actions host). diff --git a/examples/gateway/async_invoke_tools.py b/examples/gateway/async_invoke_tools.py index 90931485..473ffae7 100644 --- a/examples/gateway/async_invoke_tools.py +++ b/examples/gateway/async_invoke_tools.py @@ -1,30 +1,35 @@ -"""Async Action Gateway usage: list, invoke, and execute code. +"""Async Action Gateway session: list, invoke, and execute code. Required env: DIGITALOCEAN_TOKEN Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + END_USER_ID """ import asyncio import os -from pydo.aio import Client +from pydo.action_gateway.aio import Client async def main() -> None: client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + session = await client.sessions.create( + end_user_id=os.environ.get("END_USER_ID", "example-user"), + ) - tools = await client.gateway.tools.list(include_all=True) + tools = await session.tools.list(include_all=True) print("catalog:", [tool.name for tool in tools]) + print("MCP URL:", session.url) - output = await client.gateway.tools.invoke_one( + output = await session.tools.invoke_one( "web_search", {"query": "DigitalOcean Gradient", "max_results": 2} ) print("web_search output:", str(output)[:200]) - result = await client.gateway.code.execute("print('hello from async')") + result = await session.code.execute("print('hello from async')") print("code stdout:", result.get("stdout")) await client.close() diff --git a/examples/gateway/execute_code.py b/examples/gateway/execute_code.py index 0d4ca802..aa61cabb 100644 --- a/examples/gateway/execute_code.py +++ b/examples/gateway/execute_code.py @@ -1,19 +1,23 @@ -"""Run Python code in the Action Gateway sandbox (action.code). +"""Run Python code in the Action Gateway sandbox (action_code). Required env: DIGITALOCEAN_TOKEN Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + END_USER_ID """ import os -from pydo import Client +from pydo.action_gateway import Client client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.sessions.create( + end_user_id=os.environ.get("END_USER_ID", "example-user"), +) -result = client.gateway.code.execute( +result = session.code.execute( "import sys\n" "print('hello from the sandbox')\n" "print(sys.version)\n", thought="verify the sandbox works", ) diff --git a/examples/gateway/function_calling_loop.py b/examples/gateway/function_calling_loop.py index 192cdccc..6100d7a8 100644 --- a/examples/gateway/function_calling_loop.py +++ b/examples/gateway/function_calling_loop.py @@ -1,34 +1,30 @@ -"""Agentic function-calling loop: chat completions + Action Gateway. - -The model is handed the gateway's meta-tools (action.search / -action.invoke / action.code) so it can discover and execute tools -itself. handle_tool_calls() runs whatever the model asked for and -returns ready-to-append tool messages; the loop continues until the -model answers with plain text. +"""Agentic function-calling loop: chat completions + Action Gateway session. Required env: DIGITALOCEAN_TOKEN Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + END_USER_ID MODEL PROMPT """ import os -from pydo import Client +from pydo.action_gateway import Client client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.sessions.create( + end_user_id=os.environ.get("END_USER_ID", "example-user"), +) model = os.environ.get("MODEL", "openai-gpt-4o") prompt = os.environ.get( "PROMPT", "Find the latest news about DigitalOcean and summarize it." ) -# Meta-tools by default; use tools(include_all=True) for the concrete catalog. -tools = client.gateway.tools() - +tools = session.tools() messages = [{"role": "user", "content": prompt}] while True: @@ -42,7 +38,7 @@ break messages.append(dict(message)) - tool_messages = client.gateway.handle_tool_calls(response) + tool_messages = session.handle_tool_calls(response) for tool_message in tool_messages: print(f"[tool result] {str(tool_message['content'])[:120]}") messages.extend(tool_messages) diff --git a/examples/gateway/invoke_tools.py b/examples/gateway/invoke_tools.py index 2e61862a..62f2d02a 100644 --- a/examples/gateway/invoke_tools.py +++ b/examples/gateway/invoke_tools.py @@ -1,23 +1,23 @@ -"""Invoke Action Gateway tools in parallel (action.invoke). - -Per-tool failures are reported inside the response envelope rather than -raising, so a mixed batch always returns all results. Use -tools.invoke_one() when you want a single output or an exception. +"""Invoke Action Gateway tools in parallel (action_invoke). Required env: DIGITALOCEAN_TOKEN Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + END_USER_ID """ import os -from pydo import Client +from pydo.action_gateway import Client client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.sessions.create( + end_user_id=os.environ.get("END_USER_ID", "example-user"), +) -envelope = client.gateway.tools.invoke( +envelope = session.tools.invoke( [ { "tool": "web_search", @@ -38,8 +38,7 @@ error = result.get("error", {}) print(f" error ({error.get('class')}): {error.get('message')}") -# Single tool, direct output (raises GatewayToolError on failure): -output = client.gateway.tools.invoke_one( +output = session.tools.invoke_one( "web_search", {"query": "MCP protocol", "max_results": 1} ) print("\ninvoke_one output:", str(output)[:200]) diff --git a/examples/gateway/list_tools.py b/examples/gateway/list_tools.py index 9ba6ac6d..002faf53 100644 --- a/examples/gateway/list_tools.py +++ b/examples/gateway/list_tools.py @@ -1,26 +1,30 @@ -"""List Action Gateway tools. +"""List Action Gateway tools for a session. -By default the gateway exposes three meta-tools (action.search, -action.invoke, action.code) that let a model drive tool discovery and -execution itself. Pass include_all=True for the full concrete catalog. +By default the session exposes three meta-tools (action_search, +action_invoke, action_code). Pass include_all=True for the concrete catalog. Required env: DIGITALOCEAN_TOKEN Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + END_USER_ID """ import os -from pydo import Client +from pydo.action_gateway import Client client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.sessions.create( + end_user_id=os.environ.get("END_USER_ID", "example-user"), +) -print("Meta-tools (default):") -for tool in client.gateway.tools.list(): +print("MCP URL:", session.url) +print("\nMeta-tools (default):") +for tool in session.tools.list(): print(f" {tool.name}: {tool.get('description', '')[:80]}") print("\nFull concrete catalog:") -for tool in client.gateway.tools.list(include_all=True): +for tool in session.tools.list(include_all=True): print(f" {tool.name}: {tool.get('description', '')[:80]}") diff --git a/examples/gateway/messages_tool_use.py b/examples/gateway/messages_tool_use.py index bbeb9ea6..af2a1d8d 100644 --- a/examples/gateway/messages_tool_use.py +++ b/examples/gateway/messages_tool_use.py @@ -1,22 +1,18 @@ -"""Tool use via the Messages API (Anthropic format) + Action Gateway. - -Identical loop to function_calling_loop.py — the only change is the -provider passed at construction, which switches the tools= format and -the tool-call parsing to the Messages API shapes. +"""Tool use via the Messages API (Anthropic format) + Action Gateway session. Required env: DIGITALOCEAN_TOKEN Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + END_USER_ID MODEL PROMPT """ import os -from pydo import Client -from pydo.gateway import MessagesProvider +from pydo.action_gateway import Client, MessagesProvider def _assistant_content(response) -> list: @@ -49,14 +45,16 @@ def _print_final_message(response) -> None: token=os.environ["DIGITALOCEAN_TOKEN"], gateway_provider=MessagesProvider(), ) +session = client.sessions.create( + end_user_id=os.environ.get("END_USER_ID", "example-user"), +) model = os.environ.get("MODEL", "claude-opus-4-6") prompt = os.environ.get( "PROMPT", "Find the latest news about DigitalOcean and summarize it." ) -tools = client.gateway.tools() # Messages-format tool definitions - +tools = session.tools() messages = [{"role": "user", "content": prompt}] while True: @@ -70,7 +68,6 @@ def _print_final_message(response) -> None: break messages.append({"role": "assistant", "content": _assistant_content(response)}) - # One user turn containing all tool_result blocks: - messages.extend(client.gateway.handle_tool_calls(response)) + messages.extend(session.handle_tool_calls(response)) _print_final_message(response) diff --git a/examples/gateway/search_tools.py b/examples/gateway/search_tools.py index 35c3c598..3f86e107 100644 --- a/examples/gateway/search_tools.py +++ b/examples/gateway/search_tools.py @@ -1,22 +1,26 @@ -"""Search the Action Gateway tool catalog by use case (action.search). +"""Search the Action Gateway tool catalog by use case (action_search). Required env: DIGITALOCEAN_TOKEN Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + END_USER_ID USE_CASE """ import os -from pydo import Client +from pydo.action_gateway import Client client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.sessions.create( + end_user_id=os.environ.get("END_USER_ID", "example-user"), +) use_case = os.environ.get("USE_CASE", "search the public web for a topic") -payload = client.gateway.tools.search(use_case, limit=3) +payload = session.tools.search(use_case, limit=3) for group in payload.get("results", []): print(f"use case: {group.get('use_case')}") diff --git a/src/pydo/action_gateway/__init__.py b/src/pydo/action_gateway/__init__.py new file mode 100644 index 00000000..dcbfe98c --- /dev/null +++ b/src/pydo/action_gateway/__init__.py @@ -0,0 +1,138 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway entry point: ``from pydo.action_gateway import Client``. + +Purpose-built client for the Action Gateway. Create a session first, then +use ``session.tools`` / ``session.code`` / ``session.handle_tool_calls``. +Inference surfaces inherited from :class:`pydo.Client` (``chat``, +``messages``, ``responses``, …) remain available for agentic loops. + +Example:: + + import os + from pydo.action_gateway import Client + + client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + session = client.sessions.create(end_user_id="user-123") + + tools = session.tools() + response = client.chat.completions.create( + model="openai-gpt-4o", + messages=[{"role": "user", "content": "Search for DigitalOcean news"}], + tools=tools, + ) + messages = session.handle_tool_calls(response) +""" +from __future__ import annotations + +from typing import List, Optional + +from pydo import Client as _DigitalOceanClient +from pydo._patch import TokenCredentials +from pydo.gateway import ( + META_CODE, + META_INVOKE, + META_SEARCH, + META_TOOL_NAMES, + DEFAULT_GATEWAY_BASE_URL, + ChatCompletionsProvider, + GatewayProtocolError, + GatewayToolError, + MessagesProvider, + ResponsesProvider, + Session, + SessionsOperations, + ToolCall, + normalize_permissions, + resolve_gateway_base_url, + session_mcp_url, +) + +_GATEWAY_SURFACE: tuple = ( + "base_url", + "chat", + "messages", + "provider", + "responses", + "sessions", +) + + +class Client(_DigitalOceanClient): + """Action Gateway–focused DigitalOcean Python client. + + Primary surface: + + * ``client.sessions.create(end_user_id=...)`` → :class:`Session` + * ``session.tools`` / ``session.tools()`` — discover and wrap tools + * ``session.code`` — sandboxed Python execution + * ``session.handle_tool_calls(response)`` — run model tool calls + * ``session.url`` — MCP URL for external clients + + Inherits the full :class:`pydo.Client` machinery (auth, transport, + inference routing), so agentic loops can call ``client.chat`` / + ``client.messages`` on the same instance. + """ + + def __init__( + self, + token: Optional[str] = None, + *, + api_key: Optional[str] = None, + timeout: int = 120, + gateway_endpoint: Optional[str] = None, + gateway_provider=None, + **kwargs, + ) -> None: + super().__init__( + token=token, + api_key=api_key, + timeout=timeout, + gateway_endpoint=gateway_endpoint, + gateway_provider=gateway_provider, + **kwargs, + ) + gateway = self.gateway + if gateway is None: + raise RuntimeError( + "Action Gateway package is unavailable; " + "ensure pydo.gateway is installed" + ) + self.sessions = gateway.sessions + self.provider = gateway.provider + + @property + def base_url(self) -> Optional[str]: + """Resolved Action Gateway base URL.""" + gateway = self.gateway + return gateway.base_url if gateway is not None else None + + def __dir__(self) -> List[str]: + return sorted(set(_GATEWAY_SURFACE)) + + def __repr__(self) -> str: + return "" + + +__all__ = [ + "Client", + "TokenCredentials", + "Session", + "SessionsOperations", + "ChatCompletionsProvider", + "MessagesProvider", + "ResponsesProvider", + "GatewayToolError", + "GatewayProtocolError", + "ToolCall", + "normalize_permissions", + "session_mcp_url", + "META_SEARCH", + "META_INVOKE", + "META_CODE", + "META_TOOL_NAMES", + "DEFAULT_GATEWAY_BASE_URL", + "resolve_gateway_base_url", +] diff --git a/src/pydo/action_gateway/aio/__init__.py b/src/pydo/action_gateway/aio/__init__.py new file mode 100644 index 00000000..4d457827 --- /dev/null +++ b/src/pydo/action_gateway/aio/__init__.py @@ -0,0 +1,113 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +# pylint: disable=duplicate-code +"""Async Action Gateway entry point: ``from pydo.action_gateway.aio import Client``. + +Asynchronous twin of :class:`pydo.action_gateway.Client`. Same surface, +``await``-friendly. See :mod:`pydo.action_gateway` for usage details. +""" +from __future__ import annotations + +from typing import List, Optional + +from pydo.aio import Client as _DigitalOceanClient +from pydo.aio._patch import TokenCredentials +from pydo.aio.gateway import AsyncSession, AsyncSessionsOperations +from pydo.gateway import ( + META_CODE, + META_INVOKE, + META_SEARCH, + META_TOOL_NAMES, + DEFAULT_GATEWAY_BASE_URL, + ChatCompletionsProvider, + GatewayProtocolError, + GatewayToolError, + MessagesProvider, + ResponsesProvider, + ToolCall, + normalize_permissions, + resolve_gateway_base_url, + session_mcp_url, +) + +_GATEWAY_SURFACE: tuple = ( + "base_url", + "chat", + "messages", + "provider", + "responses", + "sessions", +) + + +class Client(_DigitalOceanClient): + """Action Gateway–focused DigitalOcean async client. + + Asynchronous counterpart to :class:`pydo.action_gateway.Client`. + Create a session with ``await client.sessions.create(end_user_id=...)``, + then use ``session.tools`` / ``session.code`` / + ``await session.handle_tool_calls(...)``. + """ + + def __init__( + self, + token: Optional[str] = None, + *, + api_key: Optional[str] = None, + timeout: int = 120, + gateway_endpoint: Optional[str] = None, + gateway_provider=None, + **kwargs, + ) -> None: + super().__init__( + token=token, + api_key=api_key, + timeout=timeout, + gateway_endpoint=gateway_endpoint, + gateway_provider=gateway_provider, + **kwargs, + ) + gateway = self.gateway + if gateway is None: + raise RuntimeError( + "Action Gateway package is unavailable; " + "ensure pydo.aio.gateway is installed" + ) + self.sessions = gateway.sessions + self.provider = gateway.provider + + @property + def base_url(self) -> Optional[str]: + """Resolved Action Gateway base URL.""" + gateway = self.gateway + return gateway.base_url if gateway is not None else None + + def __dir__(self) -> List[str]: + return sorted(set(_GATEWAY_SURFACE)) + + def __repr__(self) -> str: + return "" + + +__all__ = [ + "Client", + "TokenCredentials", + "AsyncSession", + "AsyncSessionsOperations", + "ChatCompletionsProvider", + "MessagesProvider", + "ResponsesProvider", + "GatewayToolError", + "GatewayProtocolError", + "ToolCall", + "normalize_permissions", + "session_mcp_url", + "META_SEARCH", + "META_INVOKE", + "META_CODE", + "META_TOOL_NAMES", + "DEFAULT_GATEWAY_BASE_URL", + "resolve_gateway_base_url", +] diff --git a/src/pydo/aio/gateway/__init__.py b/src/pydo/aio/gateway/__init__.py index c940ec72..9672f534 100644 --- a/src/pydo/aio/gateway/__init__.py +++ b/src/pydo/aio/gateway/__init__.py @@ -2,24 +2,26 @@ # Copyright (c) DigitalOcean. # Licensed under the Apache-2.0 License. # ------------------------------------ +# pylint: disable=duplicate-code """Async Action Gateway API — hand-written; preserved across ``make generate``.""" from __future__ import annotations from typing import Any, List, Optional, Sequence -from pydo.custom_extensions import _BaseURLProxy -from pydo.gateway import resolve_gateway_base_url from pydo.gateway.custom_models import ToolCall from pydo.gateway.providers import BaseProvider, default_provider +from pydo.gateway import resolve_gateway_base_url from .custom_operations import ( AsyncCodeOperations, AsyncGatewayTransport, AsyncMCPTransport, + AsyncRESTTransport, AsyncToolsOperations, async_execute_tool_calls, ) +from .session import AsyncSession, AsyncSessionsOperations class AsyncGatewayResources: @@ -33,21 +35,25 @@ def __init__( provider: Optional[BaseProvider] = None, transport: Optional[AsyncGatewayTransport] = None, ): - if transport is None: - proxy = _BaseURLProxy( - parent_client._client, - resolve_gateway_base_url(gateway_endpoint), - ) - transport = AsyncMCPTransport(proxy) - self._transport = transport + self._parent = parent_client + self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) self.provider = provider or default_provider() - self.tools = AsyncToolsOperations(transport, self.provider) - self.code = AsyncCodeOperations(transport) + self.sessions = AsyncSessionsOperations( + parent_client, + gateway_endpoint=gateway_endpoint, + provider=self.provider, + ) + self._transport = transport + if transport is not None: + self.tools = AsyncToolsOperations(transport, self.provider) + self.code = AsyncCodeOperations(transport) + else: + self.tools = None + self.code = None @property - def base_url(self) -> Optional[str]: - proxy = getattr(self._transport, "_client", None) - return getattr(proxy, "_base_url", None) + def base_url(self) -> str: + return self._gateway_base_url async def handle_tool_calls( self, @@ -55,11 +61,17 @@ async def handle_tool_calls( *, rationale: Optional[str] = None, ) -> List[Any]: - """Async twin of :meth:`pydo.gateway.GatewayResources.handle_tool_calls`.""" + if self.tools is None: + raise RuntimeError( + "create a session first: session = await client.sessions.create(" + "end_user_id=...); then await session.handle_tool_calls(response)" + ) calls = self.provider.extract_tool_calls(response) if not calls: return [] - results = await async_execute_tool_calls(calls, self.tools, rationale=rationale) + results = await async_execute_tool_calls( + calls, self.tools, rationale=rationale + ) return self.provider.format_tool_results(calls, results) async def execute_tool_calls( @@ -68,14 +80,21 @@ async def execute_tool_calls( *, rationale: Optional[str] = None, ) -> List[Any]: - """Execute pre-extracted :class:`ToolCall` objects; return raw outputs.""" + if self.tools is None: + raise RuntimeError( + "create a session first via await client.sessions.create(" + "end_user_id=...)" + ) return await async_execute_tool_calls(calls, self.tools, rationale=rationale) __all__ = [ "AsyncGatewayResources", + "AsyncSession", + "AsyncSessionsOperations", "AsyncGatewayTransport", "AsyncMCPTransport", + "AsyncRESTTransport", "AsyncToolsOperations", "AsyncCodeOperations", "async_execute_tool_calls", diff --git a/src/pydo/aio/gateway/custom_operations.py b/src/pydo/aio/gateway/custom_operations.py index f62e88ce..b4b14923 100644 --- a/src/pydo/aio/gateway/custom_operations.py +++ b/src/pydo/aio/gateway/custom_operations.py @@ -2,6 +2,7 @@ # Copyright (c) DigitalOcean. # Licensed under the Apache-2.0 License. # ------------------------------------ +# pylint: disable=duplicate-code """Async Action Gateway operations (mirror of :mod:`pydo.gateway`).""" from __future__ import annotations @@ -32,12 +33,21 @@ ) from pydo.gateway.providers import _error_payload, _get from pydo.gateway.transport import ( + SESSION_ID_HEADER, _MCP_HEADERS, _MCP_META_PATH, _MCP_PATH, + _META_TOOL_DEFINITIONS, + _REST_CODE_PATH, + _REST_HEADERS, + _REST_INVOKE_PATH, + _REST_SEARCH_PATH, + _REST_TOOLS_PATH, + _parse_json_body, _parse_jsonrpc, _raise_gateway_http_error, _unwrap_call_result, + _unwrap_tool_result, ) @@ -56,15 +66,22 @@ async def call_tool( class AsyncMCPTransport(AsyncGatewayTransport): """Async JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" - def __init__(self, base_url_proxy: Any): + def __init__(self, base_url_proxy: Any, *, session_id: Optional[str] = None): self._client = base_url_proxy self._ids = itertools.count(1) + self.session_id = session_id + + def _headers(self) -> Dict[str, str]: + headers = dict(_MCP_HEADERS) + if self.session_id: + headers[SESSION_ID_HEADER] = self.session_id + return headers async def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: request = HttpRequest( "POST", path, - headers=dict(_MCP_HEADERS), + headers=self._headers(), json=payload, ) request.url = self._client.format_url(request.url) @@ -110,6 +127,77 @@ async def call_tool( return _unwrap_call_result(result) +class AsyncRESTTransport(AsyncGatewayTransport): + """Async REST transport; requires ``session_id`` via ``X-Session-Id``.""" + + def __init__(self, base_url_proxy: Any, *, session_id: str): + if not session_id: + raise ValueError("session_id is required for AsyncRESTTransport") + self._client = base_url_proxy + self.session_id = session_id + + def _headers(self) -> Dict[str, str]: + headers = dict(_REST_HEADERS) + headers[SESSION_ID_HEADER] = self.session_id + return headers + + async def _request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Any: + kwargs: Dict[str, Any] = {"headers": self._headers()} + if payload is not None: + kwargs["json"] = payload + request = HttpRequest(method, path, **kwargs) + request.url = self._client.format_url(request.url) + pipeline_response = await self._client._pipeline.run(request) + response = pipeline_response.http_response + if response.status_code != 200: + try: + await response.read() + except Exception: # noqa: BLE001 + pass + _raise_gateway_http_error(response) + body = await response.read() + return _parse_json_body(body) + + async def list_tools(self, *, meta: bool) -> List[Any]: + if meta: + return _wrap([dict(tool) for tool in _META_TOOL_DEFINITIONS]) + catalog = await self._request("GET", _REST_TOOLS_PATH) + if isinstance(catalog, dict): + return _wrap(catalog.get("tools") or []) + return _wrap(catalog or []) + + async def call_tool( + self, name: str, arguments: Dict[str, Any], *, meta: bool + ) -> Any: + arguments = arguments or {} + if name == META_SEARCH: + return _unwrap_tool_result( + await self._request("POST", _REST_SEARCH_PATH, arguments) + ) + if name == META_INVOKE: + return _wrap(await self._request("POST", _REST_INVOKE_PATH, arguments)) + if name == META_CODE: + return _unwrap_tool_result( + await self._request("POST", _REST_CODE_PATH, arguments) + ) + envelope = await self._request( + "POST", + _REST_INVOKE_PATH, + {"tools": [{"tool": name, "arguments": arguments}]}, + ) + results = (envelope or {}).get("results") or [] + if not results: + raise GatewayToolError(f"invoke of {name!r} returned no results") + item = results[0] + item_result = item.get("result") if isinstance(item, dict) else item + return _unwrap_tool_result(item_result) + + class AsyncToolsOperations: """Async Action Gateway tool discovery and invocation.""" @@ -279,6 +367,7 @@ async def async_execute_tool_calls( __all__ = [ "AsyncGatewayTransport", "AsyncMCPTransport", + "AsyncRESTTransport", "AsyncToolsOperations", "AsyncCodeOperations", "async_execute_tool_calls", diff --git a/src/pydo/aio/gateway/session.py b/src/pydo/aio/gateway/session.py new file mode 100644 index 00000000..ecb90398 --- /dev/null +++ b/src/pydo/aio/gateway/session.py @@ -0,0 +1,196 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +# pylint: disable=duplicate-code +"""Async Action Gateway sessions.""" + +from __future__ import annotations + +import json as _json +import uuid +from typing import Any, Dict, List, Optional, Sequence + +from azure.core.rest import HttpRequest + +from pydo.custom_extensions import _BaseURLProxy +from pydo.gateway.custom_models import GatewayProtocolError +from pydo.gateway.providers import BaseProvider, default_provider +from pydo.gateway.session import normalize_permissions +from pydo.gateway.transport import ( + _parse_json_body, + _raise_gateway_http_error, + resolve_gateway_base_url, + session_mcp_url, +) + +from .custom_operations import ( + AsyncCodeOperations, + AsyncRESTTransport, + AsyncToolsOperations, + async_execute_tool_calls, +) + +_SESSIONS_PATH = "/v2/sessions" + + +def _pick(data: Dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in data and data[key] is not None: + return data[key] + return None + + +class AsyncSession: + """Async twin of :class:`pydo.gateway.session.Session`.""" + + def __init__( + self, + *, + session_urn: str, + end_user_id: str, + name: str, + policy: Dict[str, Any], + gateway_base_url: str, + tools: AsyncToolsOperations, + code: AsyncCodeOperations, + provider: BaseProvider, + raw: Optional[Dict[str, Any]] = None, + ): + self.session_urn = session_urn + self.id = session_urn + self.end_user_id = end_user_id + self.name = name + self.policy = policy + self._gateway_base_url = gateway_base_url.rstrip("/") + self.tools = tools + self.code = code + self.provider = provider + self.raw = raw or {} + + @property + def url(self) -> str: + return session_mcp_url(self._gateway_base_url, self.session_urn) + + async def handle_tool_calls( + self, + response: Any, + *, + rationale: Optional[str] = None, + ) -> List[Any]: + calls = self.provider.extract_tool_calls(response) + if not calls: + return [] + results = await async_execute_tool_calls( + calls, self.tools, rationale=rationale + ) + return self.provider.format_tool_results(calls, results) + + async def execute_tool_calls( + self, + calls: Sequence[Any], + *, + rationale: Optional[str] = None, + ) -> List[Any]: + return await async_execute_tool_calls(calls, self.tools, rationale=rationale) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) + + +class AsyncSessionsOperations: + """Async create via ``POST /v2/sessions`` on the DO API.""" + + def __init__( + self, + parent_client: Any, + *, + gateway_endpoint: Optional[str] = None, + provider: Optional[BaseProvider] = None, + ): + self._parent = parent_client + self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) + self._provider = provider or default_provider() + + async def create( + self, + end_user_id: str, + *, + name: Optional[str] = None, + permissions: Optional[Dict[str, Any]] = None, + ) -> AsyncSession: + if not end_user_id or not str(end_user_id).strip(): + raise ValueError("end_user_id is required") + + session_name = name or f"pydo-session-{uuid.uuid4().hex[:8]}" + policy = normalize_permissions(permissions) + body = { + "name": session_name, + "policy_json": _json.dumps(policy, separators=(",", ":")), + "end_user_id": str(end_user_id).strip(), + } + + raw_session = await self._post_create(body) + session_urn = _pick(raw_session, "sessionUrn", "session_urn") + if not session_urn: + raise GatewayProtocolError( + f"session create response missing sessionUrn: {raw_session!r}" + ) + + transport = AsyncRESTTransport( + _BaseURLProxy(self._parent._client, self._gateway_base_url), + session_id=session_urn, + ) + tools = AsyncToolsOperations(transport, self._provider) + code = AsyncCodeOperations(transport) + return AsyncSession( + session_urn=session_urn, + end_user_id=_pick(raw_session, "endUserId", "end_user_id") + or str(end_user_id).strip(), + name=_pick(raw_session, "name") or session_name, + policy=policy, + gateway_base_url=self._gateway_base_url, + tools=tools, + code=code, + provider=self._provider, + raw=raw_session, + ) + + async def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: + client = self._parent._client + request = HttpRequest( + "POST", + _SESSIONS_PATH, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json=body, + ) + request.url = client.format_url(request.url) + pipeline_response = await client._pipeline.run(request) + response = pipeline_response.http_response + if response.status_code not in (200, 201): + try: + await response.read() + except Exception: # noqa: BLE001 + pass + _raise_gateway_http_error(response) + body_bytes = await response.read() + payload = _parse_json_body(body_bytes) + if not isinstance(payload, dict): + raise GatewayProtocolError( + f"unexpected session create response: {payload!r}" + ) + session = payload.get("session") + if not isinstance(session, dict): + raise GatewayProtocolError( + f"session create response missing session object: {payload!r}" + ) + return dict(session) + + +__all__ = ["AsyncSession", "AsyncSessionsOperations"] diff --git a/src/pydo/gateway/__init__.py b/src/pydo/gateway/__init__.py index 3719d5f8..23a30f69 100644 --- a/src/pydo/gateway/__init__.py +++ b/src/pydo/gateway/__init__.py @@ -4,19 +4,17 @@ # ------------------------------------ """Action Gateway API — hand-written; preserved across ``make generate``. -Exposes tool discovery/invocation and sandboxed code execution over the -gateway's MCP endpoints, plus Composio-style providers that make gateway -tools plug directly into pydo's inference surfaces (chat completions, -messages, responses). +Session-first surface: create a session on the DigitalOcean API +(``POST /v2/sessions``), then discover/invoke tools and run code over the +gateway REST endpoints with ``X-Session-Id``. Composio-style providers make +session tools plug into pydo inference surfaces (chat completions, messages, +responses). """ from __future__ import annotations -import os from typing import Any, List, Optional, Sequence -from pydo.custom_extensions import _BaseURLProxy - from .custom_models import ( META_CODE, META_INVOKE, @@ -40,22 +38,33 @@ simplify_inference_tool_schema, simplify_messages_input_schema, ) -from .transport import MCP_PROTOCOL_VERSION, GatewayTransport, MCPTransport - -DEFAULT_GATEWAY_BASE_URL = "https://actions.do-ai.run" -_ENV_VAR = "PYDO_GATEWAY_ENDPOINT" - +from .session import ( + Session, + SessionsOperations, + normalize_permissions, + serialize_policy_json, +) +from .transport import ( + MCP_PROTOCOL_VERSION, + SESSION_ID_HEADER, + DEFAULT_GATEWAY_BASE_URL, + GatewayTransport, + MCPTransport, + RESTTransport, + resolve_gateway_base_url, + session_mcp_url, +) -def resolve_gateway_base_url(explicit: Optional[str] = None) -> str: - url = explicit or os.environ.get(_ENV_VAR) or DEFAULT_GATEWAY_BASE_URL - url = url.rstrip("/") - if "://" not in url: - url = f"https://{url}" - return url +_ENV_VAR = "PYDO_GATEWAY_ENDPOINT" # kept for docs / discoverability class GatewayResources: - """Action Gateway surface attached at ``client.gateway``.""" + """Action Gateway surface attached at ``client.gateway``. + + Primary entry point is :attr:`sessions` — create a :class:`Session` before + invoking tools. Legacy ``tools`` / ``code`` attributes require an explicit + session-bound transport and are not usable until a session exists. + """ def __init__( self, @@ -65,21 +74,27 @@ def __init__( provider: Optional[BaseProvider] = None, transport: Optional[GatewayTransport] = None, ): - if transport is None: - proxy = _BaseURLProxy( - parent_client._client, - resolve_gateway_base_url(gateway_endpoint), - ) - transport = MCPTransport(proxy) - self._transport = transport + self._parent = parent_client + self._gateway_endpoint = gateway_endpoint + self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) self.provider = provider or default_provider() - self.tools = ToolsOperations(transport, self.provider) - self.code = CodeOperations(transport) + self.sessions = SessionsOperations( + parent_client, + gateway_endpoint=gateway_endpoint, + provider=self.provider, + ) + # Optional pre-bound transport (tests). Production callers use sessions. + self._transport = transport + if transport is not None: + self.tools = ToolsOperations(transport, self.provider) + self.code = CodeOperations(transport) + else: + self.tools = None + self.code = None @property - def base_url(self) -> Optional[str]: - proxy = getattr(self._transport, "_client", None) - return getattr(proxy, "_base_url", None) + def base_url(self) -> str: + return self._gateway_base_url def handle_tool_calls( self, @@ -87,14 +102,12 @@ def handle_tool_calls( *, rationale: Optional[str] = None, ) -> List[Any]: - """Execute the tool calls in an inference response. - - Extracts tool calls using the configured provider, executes them - against the gateway (meta-tools directly; concrete tools batched - through one ``action.invoke``), and returns vendor-formatted - messages/items ready to append to the conversation. Returns an - empty list when the response contains no tool calls. - """ + """Deprecated path — prefer ``session.handle_tool_calls(response)``.""" + if self.tools is None: + raise RuntimeError( + "create a session first: session = client.sessions.create(" + "end_user_id=...); then session.handle_tool_calls(response)" + ) calls = self.provider.extract_tool_calls(response) if not calls: return [] @@ -107,18 +120,28 @@ def execute_tool_calls( *, rationale: Optional[str] = None, ) -> List[Any]: - """Execute pre-extracted :class:`ToolCall` objects; return raw outputs.""" + if self.tools is None: + raise RuntimeError( + "create a session first via client.sessions.create(end_user_id=...)" + ) return execute_tool_calls(calls, self.tools, rationale=rationale) __all__ = [ "GatewayResources", + "Session", + "SessionsOperations", + "normalize_permissions", + "serialize_policy_json", "ToolsOperations", "CodeOperations", "normalize_invoke_arguments", "GatewayTransport", + "RESTTransport", "MCPTransport", "MCP_PROTOCOL_VERSION", + "SESSION_ID_HEADER", + "session_mcp_url", "BaseProvider", "ChatCompletionsProvider", "MessagesProvider", diff --git a/src/pydo/gateway/session.py b/src/pydo/gateway/session.py new file mode 100644 index 00000000..48a197cd --- /dev/null +++ b/src/pydo/gateway/session.py @@ -0,0 +1,240 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +# pylint: disable=duplicate-code +"""Action Gateway sessions — create on the DO API, execute on the gateway.""" + +from __future__ import annotations + +import json as _json +import uuid +from typing import Any, Dict, List, Optional, Sequence + +from azure.core.rest import HttpRequest + +from pydo.custom_extensions import _BaseURLProxy + +from .custom_models import GatewayProtocolError +from .custom_operations import CodeOperations, ToolsOperations +from .providers import BaseProvider, default_provider, execute_tool_calls +from .transport import ( + RESTTransport, + _parse_json_body, + _raise_gateway_http_error, + resolve_gateway_base_url, + session_mcp_url, +) + +_SESSIONS_PATH = "/v2/sessions" +_DEFAULT_POLICY: Dict[str, Any] = {"defaultAction": "allow", "rules": []} + + +def _pick(data: Dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in data and data[key] is not None: + return data[key] + return None + + +def normalize_permissions(permissions: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Normalize SDK permissions into the wire policy object. + + Accepts snake_case ``default_action`` or wire ``defaultAction``. When + omitted, returns ``{"defaultAction": "allow", "rules": []}``. + """ + if permissions is None: + return dict(_DEFAULT_POLICY) + + default_action = ( + permissions.get("default_action") + if "default_action" in permissions + else permissions.get("defaultAction", "allow") + ) + rules_in = permissions.get("rules") or [] + rules: List[Dict[str, Any]] = [] + for rule in rules_in: + if not isinstance(rule, dict): + raise TypeError("each permissions rule must be a dict") + entry: Dict[str, Any] = {"action": rule.get("action") or "allow"} + if rule.get("tool"): + entry["tool"] = rule["tool"] + if rule.get("toolbelt"): + entry["toolbelt"] = rule["toolbelt"] + if rule.get("match"): + entry["match"] = rule["match"] + if "tool" not in entry and "toolbelt" not in entry: + raise ValueError("each permissions rule requires tool or toolbelt") + rules.append(entry) + return {"defaultAction": default_action, "rules": rules} + + +def serialize_policy_json(permissions: Optional[Dict[str, Any]]) -> str: + return _json.dumps(normalize_permissions(permissions), separators=(",", ":")) + + +class Session: + """A gateway session bound to an ``end_user_id`` and tool policy. + + Create via :meth:`SessionsOperations.create`. Use ``url`` for external + MCP clients, ``tools()`` for inference ``tools=``, and + ``handle_tool_calls`` to execute model tool calls over REST. + """ + + def __init__( + self, + *, + session_urn: str, + end_user_id: str, + name: str, + policy: Dict[str, Any], + gateway_base_url: str, + tools: ToolsOperations, + code: CodeOperations, + provider: BaseProvider, + raw: Optional[Dict[str, Any]] = None, + ): + self.session_urn = session_urn + self.id = session_urn + self.end_user_id = end_user_id + self.name = name + self.policy = policy + self._gateway_base_url = gateway_base_url.rstrip("/") + self.tools = tools + self.code = code + self.provider = provider + self.raw = raw or {} + + @property + def url(self) -> str: + """Session-pinned MCP URL for external MCP clients.""" + return session_mcp_url(self._gateway_base_url, self.session_urn) + + def handle_tool_calls( + self, + response: Any, + *, + rationale: Optional[str] = None, + ) -> List[Any]: + """Execute tool calls from an inference response against this session.""" + calls = self.provider.extract_tool_calls(response) + if not calls: + return [] + results = execute_tool_calls(calls, self.tools, rationale=rationale) + return self.provider.format_tool_results(calls, results) + + def execute_tool_calls( + self, + calls: Sequence[Any], + *, + rationale: Optional[str] = None, + ) -> List[Any]: + """Execute pre-extracted tool calls; return raw outputs.""" + return execute_tool_calls(calls, self.tools, rationale=rationale) + + def __repr__(self) -> str: # pragma: no cover - debug aid + return f"" + + +class SessionsOperations: + """Create Action Gateway sessions via ``POST /v2/sessions`` on the DO API.""" + + def __init__( + self, + parent_client: Any, + *, + gateway_endpoint: Optional[str] = None, + provider: Optional[BaseProvider] = None, + ): + self._parent = parent_client + self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) + self._provider = provider or default_provider() + + def create( + self, + end_user_id: str, + *, + name: Optional[str] = None, + permissions: Optional[Dict[str, Any]] = None, + ) -> Session: + """Create a session. + + :param end_user_id: Required end-user identifier bound to the session. + :param name: Optional display name (auto-generated when omitted). + :param permissions: Optional policy. When omitted, defaults to + ``{"defaultAction": "allow", "rules": []}``. + """ + if not end_user_id or not str(end_user_id).strip(): + raise ValueError("end_user_id is required") + + session_name = name or f"pydo-session-{uuid.uuid4().hex[:8]}" + policy = normalize_permissions(permissions) + body = { + "name": session_name, + "policy_json": _json.dumps(policy, separators=(",", ":")), + "end_user_id": str(end_user_id).strip(), + } + + raw_session = self._post_create(body) + session_urn = _pick(raw_session, "sessionUrn", "session_urn") + if not session_urn: + raise GatewayProtocolError( + f"session create response missing sessionUrn: {raw_session!r}" + ) + + transport = RESTTransport( + _BaseURLProxy(self._parent._client, self._gateway_base_url), + session_id=session_urn, + ) + tools = ToolsOperations(transport, self._provider) + code = CodeOperations(transport) + return Session( + session_urn=session_urn, + end_user_id=_pick(raw_session, "endUserId", "end_user_id") + or str(end_user_id).strip(), + name=_pick(raw_session, "name") or session_name, + policy=policy, + gateway_base_url=self._gateway_base_url, + tools=tools, + code=code, + provider=self._provider, + raw=raw_session, + ) + + def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: + client = self._parent._client + request = HttpRequest( + "POST", + _SESSIONS_PATH, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json=body, + ) + request.url = client.format_url(request.url) + pipeline_response = client._pipeline.run(request) + response = pipeline_response.http_response + if response.status_code not in (200, 201): + _raise_gateway_http_error(response) + payload = _parse_json_body( + response.text() if hasattr(response, "text") else response.body() + ) + if not isinstance(payload, dict): + raise GatewayProtocolError( + f"unexpected session create response: {payload!r}" + ) + session = payload.get("session") + if not isinstance(session, dict): + raise GatewayProtocolError( + f"session create response missing session object: {payload!r}" + ) + return dict(session) + + +__all__ = [ + "Session", + "SessionsOperations", + "normalize_permissions", + "serialize_policy_json", +] diff --git a/src/pydo/gateway/transport.py b/src/pydo/gateway/transport.py index af0712cc..33a6913c 100644 --- a/src/pydo/gateway/transport.py +++ b/src/pydo/gateway/transport.py @@ -5,21 +5,17 @@ """Action Gateway wire layer. The public SDK surface (``ToolsOperations`` / ``CodeOperations``) only talks -to the small :class:`GatewayTransport` interface. Today the gateway is -consumed over its MCP JSON-RPC endpoints (``/mcp`` and ``/mcp/meta``); when -the REST compatibility endpoints ship, a ``RESTTransport`` implementing the -same two methods can be dropped in without changing any user-facing method -or return shape. - -The gateway's MCP handler runs stateless with JSON responses, so -:class:`MCPTransport` is a plain JSON-RPC 2.0 POST per request — no -``initialize`` handshake, no session ids, no SSE parsing. +to the small :class:`GatewayTransport` interface. The default transport is +REST (``/tools/search``, ``/tools/invoke``, ``/code/execute``) and requires +a session id via ``X-Session-Id``. An :class:`MCPTransport` remains available +for callers that need JSON-RPC over ``/mcp`` / ``/mcp/meta``. """ from __future__ import annotations import itertools import json as _json +import os from typing import Any, Dict, List, Optional from azure.core.exceptions import ( @@ -34,7 +30,25 @@ from pydo.custom_extensions import _wrap -from .custom_models import GatewayProtocolError, GatewayToolError +from .custom_models import ( + META_CODE, + META_INVOKE, + META_SEARCH, + GatewayProtocolError, + GatewayToolError, + ToolResultStatus, +) + +DEFAULT_GATEWAY_BASE_URL = "https://actions.do-ai.run" +_ENV_VAR = "PYDO_GATEWAY_ENDPOINT" + + +def resolve_gateway_base_url(explicit: Optional[str] = None) -> str: + url = explicit or os.environ.get(_ENV_VAR) or DEFAULT_GATEWAY_BASE_URL + url = url.rstrip("/") + if "://" not in url: + url = f"https://{url}" + return url _ERROR_MAP = { 401: ClientAuthenticationError, @@ -44,9 +58,14 @@ } MCP_PROTOCOL_VERSION = "2025-06-18" +SESSION_ID_HEADER = "X-Session-Id" _MCP_PATH = "/mcp" _MCP_META_PATH = "/mcp/meta" +_REST_TOOLS_PATH = "/tools" +_REST_SEARCH_PATH = "/tools/search" +_REST_INVOKE_PATH = "/tools/invoke" +_REST_CODE_PATH = "/code/execute" _MCP_HEADERS = { "Content-Type": "application/json", @@ -54,6 +73,87 @@ "Accept": "application/json, text/event-stream", } +_REST_HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + +# Static meta-tool catalog for REST list(meta=True). Mirrors /mcp/meta. +_META_TOOL_DEFINITIONS: List[Dict[str, Any]] = [ + { + "name": META_SEARCH, + "title": "Action Search", + "description": ( + "Discover the catalog tools needed to satisfy one or more user " + "use cases. Call this before action_invoke whenever you need a " + "catalog tool you do not already have." + ), + "inputSchema": { + "type": "object", + "properties": { + "queries": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "object", + "properties": { + "use_case": {"type": "string"}, + "known_fields": {"type": "string"}, + }, + "required": ["use_case"], + }, + }, + "providers": {"type": "array", "items": {"type": "string"}}, + "tags": {"type": "array", "items": {"type": "string"}}, + "limit": {"type": "integer"}, + }, + "required": ["queries"], + }, + }, + { + "name": META_INVOKE, + "title": "Action Invoke", + "description": "Invoke 1–10 catalog tools in parallel.", + "inputSchema": { + "type": "object", + "properties": { + "tools": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "type": "object", + "properties": { + "tool": {"type": "string"}, + "tool_slug": {"type": "string"}, + "arguments": {"type": "object"}, + }, + }, + }, + "rationale": {"type": "string"}, + }, + "required": ["tools"], + }, + }, + { + "name": META_CODE, + "title": "Action Code", + "description": ( + "Run Python in an ephemeral sandbox. Use for computation, " + "parsing, or data processing — no prior action_search needed." + ), + "inputSchema": { + "type": "object", + "properties": { + "code": {"type": "string"}, + "code_to_execute": {"type": "string"}, + "thought": {"type": "string"}, + }, + }, + }, +] + def _response_body_text(response: Any) -> str: try: @@ -83,6 +183,13 @@ def _raise_gateway_http_error(response: Any) -> None: "team is not enabled for the Action Infra release " f"(412 Precondition Failed): {message}" ) + if response.status_code == 404 and "/v2/sessions" in ( + getattr(getattr(response, "request", None), "url", "") or "" + ): + message = ( + "session create returned 404 — is POST /v2/sessions available on " + f"this API endpoint? {message}" + ) raise HttpResponseError(message=message, response=response) @@ -95,12 +202,7 @@ def _content_text(content: Optional[List[Dict[str, Any]]]) -> str: def _unwrap_call_result(result: Dict[str, Any]) -> Any: - """Normalize an MCP ``tools/call`` result to its useful payload. - - Prefers ``structuredContent`` (typed payload); falls back to the joined - ``content`` text blocks (parsed as JSON when possible). Raises - :class:`GatewayToolError` when the tool reported ``isError``. - """ + """Normalize an MCP ``tools/call`` result to its useful payload.""" if result.get("isError"): structured = result.get("structuredContent") error = None @@ -132,15 +234,21 @@ def _unwrap_call_result(result: Dict[str, Any]) -> Any: return text -def _parse_jsonrpc(body: Any) -> Dict[str, Any]: +def _parse_json_body(body: Any) -> Any: if isinstance(body, bytes): body = body.decode("utf-8", errors="replace") + if isinstance(body, (dict, list)): + return body try: - envelope = _json.loads(body) + return _json.loads(body) except (TypeError, ValueError) as exc: raise GatewayProtocolError( f"gateway returned a non-JSON response: {body!r}" ) from exc + + +def _parse_jsonrpc(body: Any) -> Dict[str, Any]: + envelope = _parse_json_body(body) if not isinstance(envelope, dict): raise GatewayProtocolError( f"gateway returned an unexpected JSON-RPC envelope: {envelope!r}" @@ -160,6 +268,40 @@ def _parse_jsonrpc(body: Any) -> Dict[str, Any]: return result +def _decode_output(value: Any) -> Any: + if isinstance(value, (bytes, bytearray)): + value = value.decode("utf-8", errors="replace") + if isinstance(value, str): + try: + return _json.loads(value) + except (TypeError, ValueError): + return value + return value + + +def _unwrap_tool_result(payload: Any) -> Any: + """Unwrap a REST ``ToolResult`` envelope; raise on failure.""" + if not isinstance(payload, dict): + return _wrap(payload) + status = payload.get("status") + if status and status != ToolResultStatus.SUCCEEDED: + error = payload.get("error") or {} + raise GatewayToolError.from_error_payload( + dict(error) if isinstance(error, dict) else {"message": str(error)}, + invocation_id=payload.get("invocation_id") or payload.get("call_id"), + ) + if "output" in payload: + return _wrap(_decode_output(payload.get("output"))) + return _wrap(payload) + + +def session_mcp_url(gateway_base_url: str, session_urn: str) -> str: + """Build the session-pinned MCP URL for external MCP clients.""" + base = gateway_base_url.rstrip("/") + session_id = session_urn.rsplit(":", 1)[-1] + return f"{base}/mcp/session/{session_id}" + + class GatewayTransport: """Swappable wire layer; MCP semantics are the lowest common denominator.""" @@ -170,20 +312,93 @@ def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: raise NotImplementedError +class RESTTransport(GatewayTransport): + """REST over ``/tools``, ``/tools/search``, ``/tools/invoke``, ``/code/execute``. + + Requires ``session_id`` (session URN) on every request via ``X-Session-Id``. + """ + + def __init__(self, base_url_proxy: Any, *, session_id: str): + if not session_id: + raise ValueError("session_id is required for RESTTransport") + self._client = base_url_proxy + self.session_id = session_id + + def _headers(self) -> Dict[str, str]: + headers = dict(_REST_HEADERS) + headers[SESSION_ID_HEADER] = self.session_id + return headers + + def _request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Any: + kwargs: Dict[str, Any] = {"headers": self._headers()} + if payload is not None: + kwargs["json"] = payload + request = HttpRequest(method, path, **kwargs) + request.url = self._client.format_url(request.url) + pipeline_response = self._client._pipeline.run(request) + response = pipeline_response.http_response + if response.status_code != 200: + _raise_gateway_http_error(response) + body = response.text() if hasattr(response, "text") else response.body() + return _parse_json_body(body) + + def list_tools(self, *, meta: bool) -> List[Any]: + if meta: + return _wrap([dict(tool) for tool in _META_TOOL_DEFINITIONS]) + catalog = self._request("GET", _REST_TOOLS_PATH) + if isinstance(catalog, dict): + return _wrap(catalog.get("tools") or []) + return _wrap(catalog or []) + + def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: + arguments = arguments or {} + if name == META_SEARCH or (meta and name == META_SEARCH): + return _unwrap_tool_result( + self._request("POST", _REST_SEARCH_PATH, arguments) + ) + if name == META_INVOKE or (meta and name == META_INVOKE): + # Invoke returns the batch envelope directly (not ToolResult). + return _wrap(self._request("POST", _REST_INVOKE_PATH, arguments)) + if name == META_CODE or (meta and name == META_CODE): + return _unwrap_tool_result(self._request("POST", _REST_CODE_PATH, arguments)) + # Concrete catalog tool → single-item invoke. + envelope = self._request( + "POST", + _REST_INVOKE_PATH, + {"tools": [{"tool": name, "arguments": arguments}]}, + ) + results = (envelope or {}).get("results") or [] + if not results: + raise GatewayToolError(f"invoke of {name!r} returned no results") + item = results[0] + item_result = item.get("result") if isinstance(item, dict) else item + return _unwrap_tool_result(item_result) + + class MCPTransport(GatewayTransport): """JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" - def __init__(self, base_url_proxy: Any): + def __init__(self, base_url_proxy: Any, *, session_id: Optional[str] = None): self._client = base_url_proxy self._ids = itertools.count(1) + self.session_id = session_id - # -- wire plumbing ---------------------------------------------------- + def _headers(self) -> Dict[str, str]: + headers = dict(_MCP_HEADERS) + if self.session_id: + headers[SESSION_ID_HEADER] = self.session_id + return headers def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: request = HttpRequest( "POST", path, - headers=dict(_MCP_HEADERS), + headers=self._headers(), json=payload, ) request.url = self._client.format_url(request.url) @@ -210,8 +425,6 @@ def _rpc( payload["params"] = params return self._post(_MCP_META_PATH if meta else _MCP_PATH, payload) - # -- GatewayTransport ------------------------------------------------- - def list_tools(self, *, meta: bool) -> List[Any]: result = self._rpc("tools/list", meta=meta) return _wrap(result.get("tools") or []) @@ -227,6 +440,11 @@ def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: __all__ = [ "GatewayTransport", + "RESTTransport", "MCPTransport", "MCP_PROTOCOL_VERSION", + "SESSION_ID_HEADER", + "session_mcp_url", + "DEFAULT_GATEWAY_BASE_URL", + "resolve_gateway_base_url", ] diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index 827375ea..21719b2c 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -9,11 +9,16 @@ import json from types import SimpleNamespace -from typing import Any, List +from typing import Any, List, Optional from unittest.mock import MagicMock -from pydo.gateway import GatewayResources from pydo.aio.gateway import AsyncGatewayResources +from pydo.aio.gateway.custom_operations import AsyncRESTTransport +from pydo.custom_extensions import _BaseURLProxy +from pydo.gateway import GatewayResources, RESTTransport + +TEST_SESSION_URN = "do:managed_agents_session:test-session" +TEST_GATEWAY_URL = "https://actions.do-ai-test.run" class FakeResponse: @@ -83,6 +88,7 @@ def jsonrpc_error(code: int, message: str, *, rpc_id: int = 1) -> dict: def call_result( structured: Any = None, *, is_error: bool = False, text: str = "" ) -> dict: + """MCP tools/call result shape (legacy helper for MCP-specific tests).""" result: dict = {"isError": is_error} if structured is not None: result["structuredContent"] = structured @@ -91,6 +97,15 @@ def call_result( return result +def tool_result( + output: Any = None, *, error: Any = None, call_id: str = "call_1" +) -> dict: + """REST ToolResult envelope (search / code).""" + if error is not None: + return {"status": "failed", "error": error, "call_id": call_id} + return {"status": "succeeded", "output": output, "call_id": call_id} + + def invoke_envelope( results: List[dict] | None = None, *, @@ -123,27 +138,102 @@ def invoke_envelope( } -def make_gateway(responses: List[FakeResponse], provider=None) -> GatewayResources: +def chat_tool_response( + *, + name: str = "web_search", + arguments: str = '{"query": "do"}', + call_id: str = "call_1", +) -> dict: + """Build a chat-completions response containing one tool call.""" + return { + "choices": [ + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": name, + "arguments": arguments, + }, + } + ], + } + } + ] + } + + +def session_create_response( + *, + session_urn: str = TEST_SESSION_URN, + end_user_id: str = "user-123", + name: str = "test-session", +) -> dict: + return { + "session": { + "sessionUrn": session_urn, + "teamId": "42", + "name": name, + "policyJson": '{"defaultAction":"allow","rules":[]}', + "endUserId": end_user_id, + } + } + + +def make_parent(responses: List[FakeResponse]) -> MagicMock: parent = MagicMock() parent._client = MagicMock() parent._client._pipeline = FakePipeline(responses) + parent._client.format_url = lambda url, **_kwargs: ( + url if str(url).startswith("http") else f"https://api.digitalocean.com{url}" + ) + return parent + + +def make_async_parent(responses: List[AsyncFakeResponse]) -> MagicMock: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = AsyncFakePipeline(responses) + parent._client.format_url = lambda url, **_kwargs: ( + url if str(url).startswith("http") else f"https://api.digitalocean.com{url}" + ) + return parent + + +def make_gateway( + responses: List[FakeResponse], + provider=None, + *, + session_id: str = TEST_SESSION_URN, +) -> GatewayResources: + parent = make_parent(responses) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = RESTTransport(proxy, session_id=session_id) return GatewayResources( parent, - gateway_endpoint="https://actions.do-ai-test.run", + gateway_endpoint=TEST_GATEWAY_URL, provider=provider, + transport=transport, ) def make_async_gateway( - responses: List[AsyncFakeResponse], provider=None + responses: List[AsyncFakeResponse], + provider=None, + *, + session_id: str = TEST_SESSION_URN, ) -> AsyncGatewayResources: - parent = MagicMock() - parent._client = MagicMock() - parent._client._pipeline = AsyncFakePipeline(responses) + parent = make_async_parent(responses) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = AsyncRESTTransport(proxy, session_id=session_id) return AsyncGatewayResources( parent, - gateway_endpoint="https://actions.do-ai-test.run", + gateway_endpoint=TEST_GATEWAY_URL, provider=provider, + transport=transport, ) @@ -155,9 +245,13 @@ def sent_request(gateway, index: int = 0) -> Any: return pipeline_of(gateway).calls[index].request -def sent_payload(gateway, index: int = 0) -> dict: +def sent_payload(gateway, index: int = 0) -> Optional[dict]: request = sent_request(gateway, index) content = request.content + if content is None: + return None if isinstance(content, bytes): content = content.decode("utf-8") + if not content: + return None return json.loads(content) diff --git a/tests/gateway/test_action_gateway_client.py b/tests/gateway/test_action_gateway_client.py new file mode 100644 index 00000000..af91318f --- /dev/null +++ b/tests/gateway/test_action_gateway_client.py @@ -0,0 +1,123 @@ +# pylint: disable=missing-function-docstring,protected-access,missing-class-docstring,too-few-public-methods,import-outside-toplevel +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Smoke tests for ``from pydo.action_gateway import Client``.""" + +from __future__ import annotations + +import json + +import pytest + +import pydo +import pydo.action_gateway +import pydo.aio +from pydo.action_gateway import Client as ActionGatewayClient +from pydo.gateway import ChatCompletionsProvider, MessagesProvider + +from .conftest import ( + FakeResponse, + chat_tool_response, + invoke_envelope, + session_create_response, +) + +try: + import aiohttp # pylint: disable=unused-import + + _HAS_AIO = True +except ImportError: # pragma: no cover + _HAS_AIO = False + + +def test_namespace_module_exports(): + assert hasattr(pydo.action_gateway, "Client") + assert hasattr(pydo.action_gateway, "Session") + assert hasattr(pydo.action_gateway, "TokenCredentials") + assert "Client" in pydo.action_gateway.__all__ + + +def test_namespace_client_is_subclass_of_core_client(): + assert issubclass(ActionGatewayClient, pydo.Client) + + +def test_namespace_client_dir_is_gateway_focused(): + client = ActionGatewayClient(token="dummy") + surface = set(dir(client)) + expected = { + "sessions", + "provider", + "base_url", + "chat", + "messages", + "responses", + } + assert expected <= surface + for attr in ("tools", "code", "handle_tool_calls", "droplets"): + assert attr not in surface + + +def test_namespace_client_repr_is_distinct(): + client = ActionGatewayClient(token="dummy") + assert repr(client) == "" + + +def test_sessions_delegate_to_gateway(): + client = ActionGatewayClient(token="dummy") + assert client.sessions is client.gateway.sessions + assert client.provider is client.gateway.provider + + +def test_gateway_provider_kwarg(): + client = ActionGatewayClient( + token="dummy", + gateway_provider=MessagesProvider(), + ) + assert isinstance(client.provider, MessagesProvider) + assert not isinstance(client.provider, ChatCompletionsProvider) + + +def test_session_create_and_handle_tool_calls(monkeypatch): + responses = [ + FakeResponse(200, session_create_response()), + FakeResponse(200, invoke_envelope(output={"ok": True})), + ] + client = ActionGatewayClient(token="dummy") + + class Pipeline: + def __init__(self): + self.calls = [] + + def run(self, request, **_kwargs): + self.calls.append(request) + return type("R", (), {"http_response": responses.pop(0)})() + + monkeypatch.setattr(client._client, "_pipeline", Pipeline()) + + session = client.sessions.create(end_user_id="user-123") + tools = session.tools() + assert tools[0]["function"]["name"] == "action_search" + assert "mcp/session/" in session.url + + messages = session.handle_tool_calls(chat_tool_response()) + assert messages[0]["role"] == "tool" + create_body = json.loads( + client._client._pipeline.calls[0].content + if isinstance(client._client._pipeline.calls[0].content, str) + else client._client._pipeline.calls[0].content.decode("utf-8") + ) + assert create_body["end_user_id"] == "user-123" + + +@pytest.mark.skipif(not _HAS_AIO, reason="aiohttp extra not installed") +def test_async_namespace_mirrors_sync(): + import pydo.action_gateway.aio as action_gateway_aio + from pydo.action_gateway.aio import Client as AsyncActionGatewayClient + + assert hasattr(action_gateway_aio, "Client") + assert issubclass(action_gateway_aio.Client, pydo.aio.Client) + client = AsyncActionGatewayClient(token="dummy") + assert repr(client) == "" + assert client.sessions is client.gateway.sessions diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index 431ba974..87dc8634 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -12,14 +12,15 @@ import pytest -from pydo.gateway import ChatCompletionsProvider, GatewayToolError +from pydo.gateway import ChatCompletionsProvider, GatewayToolError, SESSION_ID_HEADER from .conftest import ( + TEST_SESSION_URN, AsyncFakeResponse, - call_result, + chat_tool_response, invoke_envelope, - jsonrpc_result, make_async_gateway, + tool_result, ) @@ -27,87 +28,62 @@ def _run(coro): return asyncio.run(coro) -def _sent_payload(gateway, index=0): +def _sent_request(gateway, index=0): pipeline = gateway._transport._client._original._pipeline - content = pipeline.calls[index].request.content + return pipeline.calls[index].request + + +def _sent_payload(gateway, index=0): + content = _sent_request(gateway, index).content if isinstance(content, bytes): content = content.decode("utf-8") return json.loads(content) def test_list_defaults_to_meta(): - gateway = make_async_gateway( - [AsyncFakeResponse(200, jsonrpc_result({"tools": [{"name": "action_search"}]}))] - ) + gateway = make_async_gateway([]) tools = _run(gateway.tools.list()) - assert tools[0].name == "action_search" - pipeline = gateway._transport._client._original._pipeline - assert pipeline.calls[0].request.url.endswith("/mcp/meta") + assert [t.name for t in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] def test_invoke_and_invoke_one(): envelope = invoke_envelope(output={"answer": 7}) - gateway = make_async_gateway( - [AsyncFakeResponse(200, jsonrpc_result(call_result(envelope)))] - ) + gateway = make_async_gateway([AsyncFakeResponse(200, envelope)]) output = _run(gateway.tools.invoke_one("web_search", {"query": "do"})) assert output.answer == 7 - params = _sent_payload(gateway)["params"] - assert params["name"] == "action_invoke" + request = _sent_request(gateway) + assert request.url.endswith("/tools/invoke") + assert request.headers[SESSION_ID_HEADER] == TEST_SESSION_URN + assert _sent_payload(gateway)["tools"][0]["tool"] == "web_search" def test_code_execute_failure_raises(): - structured = {"error": {"class": "execution_failed", "message": "crash"}} gateway = make_async_gateway( - [AsyncFakeResponse(200, jsonrpc_result(call_result(structured, is_error=True)))] + [ + AsyncFakeResponse( + 200, + tool_result(error={"class": "execution_failed", "message": "crash"}), + ) + ] ) with pytest.raises(GatewayToolError, match="crash"): _run(gateway.code.execute("1/0")) def test_tools_callable_and_handle_tool_calls(): - meta_tools = [ - { - "name": "action_search", - "description": "d", - "inputSchema": {"type": "object"}, - }, - { - "name": "action_invoke", - "description": "d", - "inputSchema": {"type": "object"}, - }, - {"name": "action_code", "description": "d", "inputSchema": {"type": "object"}}, - ] envelope = invoke_envelope(output={"ok": True}) gateway = make_async_gateway( - [ - AsyncFakeResponse(200, jsonrpc_result({"tools": meta_tools})), - AsyncFakeResponse(200, jsonrpc_result(call_result(envelope))), - ], + [AsyncFakeResponse(200, envelope)], provider=ChatCompletionsProvider(), ) async def scenario(): tools = await gateway.tools() - response = { - "choices": [ - { - "message": { - "tool_calls": [ - { - "id": "call_1", - "function": { - "name": "web_search", - "arguments": '{"query": "do"}', - }, - } - ] - } - } - ] - } - messages = await gateway.handle_tool_calls(response) + messages = await gateway.handle_tool_calls(chat_tool_response()) return tools, messages tools, messages = _run(scenario()) diff --git a/tests/gateway/test_code.py b/tests/gateway/test_code.py index c1d8640f..bc79a89c 100644 --- a/tests/gateway/test_code.py +++ b/tests/gateway/test_code.py @@ -1,4 +1,4 @@ -# pylint: disable=missing-function-docstring,protected-access +# pylint: disable=missing-function-docstring,protected-access,duplicate-code # ------------------------------------ # Copyright (c) DigitalOcean. # Licensed under the Apache-2.0 License. @@ -13,24 +13,22 @@ from .conftest import ( FakeResponse, - call_result, - jsonrpc_result, make_gateway, sent_payload, sent_request, + tool_result, ) def test_execute_happy_path(): output = {"stdout": "hello\n", "stderr": "", "exit_code": 0} - gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(output)))]) + gateway = make_gateway([FakeResponse(200, tool_result(output))]) result = gateway.code.execute("print('hello')", thought="say hello") request = sent_request(gateway) - assert request.url.endswith("/mcp/meta") - params = sent_payload(gateway)["params"] - assert params["name"] == "action_code" - assert params["arguments"] == { + assert request.url.endswith("/code/execute") + payload = sent_payload(gateway) + assert payload == { "code": "print('hello')", "thought": "say hello", } @@ -41,9 +39,9 @@ def test_execute_happy_path(): def test_execute_omits_empty_thought(): output = {"stdout": "", "stderr": "", "exit_code": 0} - gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(output)))]) + gateway = make_gateway([FakeResponse(200, tool_result(output))]) gateway.code.execute("pass") - assert "thought" not in sent_payload(gateway)["params"]["arguments"] + assert "thought" not in sent_payload(gateway) def test_execute_rejects_empty_code(): @@ -53,15 +51,19 @@ def test_execute_rejects_empty_code(): def test_execute_sandbox_failure_raises(): - structured = { - "error": { - "class": "execution_failed", - "message": "sandbox crashed", - "retriable": False, - } - } gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result(structured, is_error=True)))] + [ + FakeResponse( + 200, + tool_result( + error={ + "class": "execution_failed", + "message": "sandbox crashed", + "retriable": False, + } + ), + ) + ] ) with pytest.raises(GatewayToolError, match="sandbox crashed") as excinfo: gateway.code.execute("1/0") diff --git a/tests/gateway/test_providers.py b/tests/gateway/test_providers.py index b6413072..db36b668 100644 --- a/tests/gateway/test_providers.py +++ b/tests/gateway/test_providers.py @@ -22,11 +22,12 @@ from .conftest import ( FakeResponse, - call_result, + chat_tool_response, invoke_envelope, - jsonrpc_result, make_gateway, sent_payload, + sent_request, + tool_result, ) _CATALOG = [ @@ -165,25 +166,7 @@ def test_wrap_tools_falls_back_to_title_and_empty_schema(): def _chat_response(arguments='{"query": "do"}'): - return { - "choices": [ - { - "message": { - "role": "assistant", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "web_search", - "arguments": arguments, - }, - } - ], - } - } - ] - } + return chat_tool_response(arguments=arguments) def _messages_response(): @@ -280,10 +263,7 @@ def test_format_results_per_provider(): def test_tools_callable_wraps_meta_tools_by_default(): - gateway = make_gateway( - [FakeResponse(200, jsonrpc_result({"tools": _META_TOOLS}))], - provider=ChatCompletionsProvider(), - ) + gateway = make_gateway([], provider=ChatCompletionsProvider()) tools = gateway.tools() assert [t["function"]["name"] for t in tools] == [ "action_search", @@ -293,10 +273,7 @@ def test_tools_callable_wraps_meta_tools_by_default(): def test_tools_callable_wraps_meta_tools_for_messages(): - gateway = make_gateway( - [FakeResponse(200, jsonrpc_result({"tools": _META_TOOLS}))], - provider=MessagesProvider(), - ) + gateway = make_gateway([], provider=MessagesProvider()) tools = gateway.tools() assert [t["name"] for t in tools] == [ "action_search", @@ -307,7 +284,7 @@ def test_tools_callable_wraps_meta_tools_for_messages(): def test_tools_callable_include_all_wraps_catalog(): gateway = make_gateway( - [FakeResponse(200, jsonrpc_result({"tools": _CATALOG}))], + [FakeResponse(200, {"tools": _CATALOG})], provider=ChatCompletionsProvider(), ) tools = gateway.tools(include_all=True) @@ -317,8 +294,8 @@ def test_tools_callable_include_all_wraps_catalog(): def test_tools_callable_names_filter_and_missing(): gateway = make_gateway( [ - FakeResponse(200, jsonrpc_result({"tools": _CATALOG})), - FakeResponse(200, jsonrpc_result({"tools": _CATALOG})), + FakeResponse(200, {"tools": _CATALOG}), + FakeResponse(200, {"tools": _CATALOG}), ], provider=ChatCompletionsProvider(), ) @@ -339,7 +316,7 @@ def test_tools_callable_via_search(): ] } gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result(search_payload)))], + [FakeResponse(200, tool_result(search_payload))], provider=ChatCompletionsProvider(), ) tools = gateway.tools(search="search the web", limit=2) @@ -353,15 +330,15 @@ def test_tools_callable_via_search(): def test_handle_tool_calls_batches_concrete_tools(): envelope = invoke_envelope(output={"answer": 42}) gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result(envelope)))], + [FakeResponse(200, envelope)], provider=ChatCompletionsProvider(), ) messages = gateway.handle_tool_calls(_chat_response(), rationale="why not") - params = sent_payload(gateway)["params"] - assert params["name"] == "action_invoke" - assert params["arguments"]["rationale"] == "why not" - assert params["arguments"]["tools"] == [ + assert sent_request(gateway).url.endswith("/tools/invoke") + payload = sent_payload(gateway) + assert payload["rationale"] == "why not" + assert payload["tools"] == [ {"tool": "web_search", "arguments": {"query": "do"}} ] @@ -392,13 +369,9 @@ def test_normalize_invoke_arguments_accepts_chat_function_shape(): def test_handle_tool_calls_normalizes_action_invoke_payload(): envelope = invoke_envelope(output={"answer": 1}) gateway = make_gateway( - [ - FakeResponse(200, jsonrpc_result({"tools": _META_TOOLS})), - FakeResponse(200, jsonrpc_result(call_result(envelope))), - ], + [FakeResponse(200, envelope)], provider=ChatCompletionsProvider(), ) - gateway.tools() response = { "choices": [ { @@ -430,9 +403,9 @@ def test_handle_tool_calls_normalizes_action_invoke_payload(): ] } messages = gateway.handle_tool_calls(response) - params = sent_payload(gateway, 1)["params"] - assert params["name"] == "action_invoke" - assert params["arguments"]["tools"] == [ + assert sent_request(gateway).url.endswith("/tools/invoke") + payload = sent_payload(gateway) + assert payload["tools"] == [ {"tool": "web_search", "arguments": {"query": "digitalocean"}} ] assert json.loads(messages[0]["content"]) == envelope @@ -440,13 +413,9 @@ def test_handle_tool_calls_normalizes_action_invoke_payload(): def test_handle_tool_calls_routes_meta_tools_directly(): gateway = make_gateway( - [ - FakeResponse(200, jsonrpc_result({"tools": _META_TOOLS})), - FakeResponse(200, jsonrpc_result(call_result({"results": []}))), - ], + [FakeResponse(200, tool_result({"results": []}))], provider=ChatCompletionsProvider(), ) - gateway.tools() response = { "choices": [ { @@ -465,8 +434,7 @@ def test_handle_tool_calls_routes_meta_tools_directly(): ] } messages = gateway.handle_tool_calls(response) - params = sent_payload(gateway, 1)["params"] - assert params["name"] == "action_search" + assert sent_request(gateway).url.endswith("/tools/search") assert json.loads(messages[0]["content"]) == {"results": []} @@ -475,7 +443,7 @@ def test_handle_tool_calls_surfaces_failures_as_content(): error={"class": "timeout", "message": "too slow"}, ) gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result(envelope)))], + [FakeResponse(200, envelope)], provider=ChatCompletionsProvider(), ) messages = gateway.handle_tool_calls(_chat_response()) diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py new file mode 100644 index 00000000..8507222f --- /dev/null +++ b/tests/gateway/test_session.py @@ -0,0 +1,129 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for Action Gateway sessions.""" + +from __future__ import annotations + +import json + +import pytest + +from pydo.gateway import ( + SESSION_ID_HEADER, + SessionsOperations, + normalize_permissions, +) +from pydo.gateway.session import serialize_policy_json + +from .conftest import ( + TEST_GATEWAY_URL, + TEST_SESSION_URN, + FakeResponse, + chat_tool_response, + invoke_envelope, + make_parent, + session_create_response, +) + + +def test_normalize_permissions_defaults_to_allow(): + assert normalize_permissions(None) == {"defaultAction": "allow", "rules": []} + + +def test_normalize_permissions_accepts_snake_case(): + policy = normalize_permissions( + { + "default_action": "ask", + "rules": [ + {"toolbelt": "read-only@1.2.3", "action": "allow"}, + {"tool": "gmail", "action": "deny"}, + ], + } + ) + assert policy == { + "defaultAction": "ask", + "rules": [ + {"toolbelt": "read-only@1.2.3", "action": "allow"}, + {"tool": "gmail", "action": "deny"}, + ], + } + + +def test_normalize_permissions_requires_tool_or_toolbelt(): + with pytest.raises(ValueError, match="tool or toolbelt"): + normalize_permissions({"rules": [{"action": "allow"}]}) + + +def test_serialize_policy_json(): + assert json.loads(serialize_policy_json(None))["defaultAction"] == "allow" + + +def test_sessions_create_requires_end_user_id(): + ops = SessionsOperations(make_parent([]), gateway_endpoint=TEST_GATEWAY_URL) + with pytest.raises(ValueError, match="end_user_id"): + ops.create("") + + +def test_sessions_create_posts_to_do_api_and_binds_rest(): + parent = make_parent( + [ + FakeResponse(200, session_create_response()), + FakeResponse(200, invoke_envelope(output={"ok": True})), + ] + ) + ops = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + session = ops.create("user-123") + + create_req = parent._client._pipeline.calls[0].request + assert create_req.method == "POST" + assert create_req.url.endswith("/v2/sessions") + body = json.loads(create_req.content) + assert body["end_user_id"] == "user-123" + assert json.loads(body["policy_json"]) == { + "defaultAction": "allow", + "rules": [], + } + assert body["name"].startswith("pydo-session-") + + assert session.session_urn == TEST_SESSION_URN + assert session.end_user_id == "user-123" + assert session.url == "https://actions.do-ai-test.run/mcp/session/test-session" + + tools = session.tools() + assert [t["function"]["name"] for t in tools][:1] == ["action_search"] + + messages = session.handle_tool_calls(chat_tool_response()) + invoke_req = parent._client._pipeline.calls[1].request + assert invoke_req.url.endswith("/tools/invoke") + assert invoke_req.headers[SESSION_ID_HEADER] == TEST_SESSION_URN + assert messages[0]["role"] == "tool" + + +def test_sessions_create_with_permissions_and_name(): + parent = make_parent( + [ + FakeResponse( + 200, + session_create_response(name="named", end_user_id="u1"), + ) + ] + ) + ops = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + session = ops.create( + "u1", + name="named", + permissions={ + "default_action": "deny", + "rules": [{"tool": "web_search", "action": "allow"}], + }, + ) + body = json.loads(parent._client._pipeline.calls[0].request.content) + assert body["name"] == "named" + assert json.loads(body["policy_json"]) == { + "defaultAction": "deny", + "rules": [{"tool": "web_search", "action": "allow"}], + } + assert session.name == "named" diff --git a/tests/gateway/test_tools.py b/tests/gateway/test_tools.py index 662b0121..c1e17ebe 100644 --- a/tests/gateway/test_tools.py +++ b/tests/gateway/test_tools.py @@ -13,11 +13,11 @@ from .conftest import ( FakeResponse, - call_result, invoke_envelope, - jsonrpc_result, make_gateway, sent_payload, + sent_request, + tool_result, ) _SEARCH_PAYLOAD = { @@ -43,21 +43,16 @@ def test_search_accepts_single_string(): - gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result(_SEARCH_PAYLOAD)))] - ) + gateway = make_gateway([FakeResponse(200, tool_result(_SEARCH_PAYLOAD))]) result = gateway.tools.search("search the web") - params = sent_payload(gateway)["params"] - assert params["name"] == "action_search" - assert params["arguments"]["queries"] == [{"use_case": "search the web"}] + assert sent_request(gateway).url.endswith("/tools/search") + assert sent_payload(gateway)["queries"] == [{"use_case": "search the web"}] assert result.results[0].results[0].name == "web_search" def test_search_accepts_dicts_and_filters(): - gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result(_SEARCH_PAYLOAD)))] - ) + gateway = make_gateway([FakeResponse(200, tool_result(_SEARCH_PAYLOAD))]) gateway.tools.search( [ {"use_case": "find stuff", "known_fields": "site:example.com"}, @@ -67,7 +62,7 @@ def test_search_accepts_dicts_and_filters(): tags=["web"], limit=3, ) - arguments = sent_payload(gateway)["params"]["arguments"] + arguments = sent_payload(gateway) assert arguments["queries"] == [ {"use_case": "find stuff", "known_fields": "site:example.com"}, {"use_case": "another use case"}, @@ -108,7 +103,7 @@ def test_invoke_shapes_arguments_and_returns_envelope(): }, ] ) - gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(envelope)))]) + gateway = make_gateway([FakeResponse(200, envelope)]) result = gateway.tools.invoke( [ {"tool": "web_search", "arguments": {"query": "do"}}, @@ -117,15 +112,14 @@ def test_invoke_shapes_arguments_and_returns_envelope(): rationale="testing", ) - params = sent_payload(gateway)["params"] - assert params["name"] == "action_invoke" - assert params["arguments"]["rationale"] == "testing" - assert params["arguments"]["tools"] == [ + assert sent_request(gateway).url.endswith("/tools/invoke") + payload = sent_payload(gateway) + assert payload["rationale"] == "testing" + assert payload["tools"] == [ {"tool": "web_search", "arguments": {"query": "do"}}, {"tool": "missing_tool", "arguments": {}}, ] - # per-item failures stay in the envelope — no raise assert result.error_count == 1 assert result.results[1].result.status == "failed" @@ -152,7 +146,7 @@ def test_invoke_one_returns_output(): } ] ) - gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(envelope)))]) + gateway = make_gateway([FakeResponse(200, envelope)]) output = gateway.tools.invoke_one("web_search", {"query": "do"}) assert output.answer == 42 @@ -166,21 +160,8 @@ def test_invoke_one_raises_on_failure(): }, invocation_id="inv_9", ) - gateway = make_gateway([FakeResponse(200, jsonrpc_result(call_result(envelope)))]) + gateway = make_gateway([FakeResponse(200, envelope)]) with pytest.raises(GatewayToolError, match="exa is down") as excinfo: gateway.tools.invoke_one("web_search", {"query": "do"}) assert excinfo.value.error_class == "upstream_error" assert excinfo.value.retriable is True - - -# -- concrete call ----------------------------------------------------------- - - -def test_call_hits_concrete_endpoint(): - gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result({"provider": "exa"})))] - ) - result = gateway.tools.call("web_search", {"query": "do"}) - payload = sent_payload(gateway) - assert payload["params"]["name"] == "web_search" - assert result.provider == "exa" diff --git a/tests/gateway/test_transport.py b/tests/gateway/test_transport.py index d6003887..2a306835 100644 --- a/tests/gateway/test_transport.py +++ b/tests/gateway/test_transport.py @@ -1,9 +1,9 @@ -# pylint: disable=missing-function-docstring,protected-access +# pylint: disable=missing-function-docstring,protected-access,duplicate-code # ------------------------------------ # Copyright (c) DigitalOcean. # Licensed under the Apache-2.0 License. # ------------------------------------ -"""Unit tests for :mod:`pydo.gateway.transport` (MCP JSON-RPC wire layer).""" +"""Unit tests for :mod:`pydo.gateway.transport` (REST + MCP wire layers).""" from __future__ import annotations @@ -14,145 +14,134 @@ ResourceNotFoundError, ) +from pydo.custom_extensions import _BaseURLProxy from pydo.gateway import ( - MCP_PROTOCOL_VERSION, GatewayProtocolError, + GatewayResources, GatewayToolError, + MCPTransport, + SESSION_ID_HEADER, ) +from pydo.gateway.transport import session_mcp_url from .conftest import ( + TEST_GATEWAY_URL, + TEST_SESSION_URN, FakeResponse, call_result, + invoke_envelope, jsonrpc_error, jsonrpc_result, make_gateway, + make_parent, sent_payload, sent_request, + tool_result, ) -def test_list_tools_posts_jsonrpc_to_meta_endpoint(): +def test_list_meta_tools_is_local_no_network(): + gateway = make_gateway([]) + tools = gateway.tools.list() + assert [t.name for t in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + assert pipeline_calls(gateway) == 0 + + +def pipeline_calls(gateway) -> int: + return len(gateway._transport._client._original._pipeline.calls) + + +def test_list_tools_include_all_hits_rest_catalog(): gateway = make_gateway( - [FakeResponse(200, jsonrpc_result({"tools": [{"name": "action_search"}]}))] + [FakeResponse(200, {"tools": [{"name": "web_search"}]})] ) - tools = gateway.tools.list() + tools = gateway.tools.list(include_all=True) + request = sent_request(gateway) + assert request.method == "GET" + assert request.url.endswith("/tools") + assert request.headers[SESSION_ID_HEADER] == TEST_SESSION_URN + assert tools[0].name == "web_search" + +def test_search_posts_rest_and_unwraps_tool_result(): + gateway = make_gateway( + [ + FakeResponse( + 200, + tool_result({"results": [{"use_case": "x", "results": []}]}), + ) + ] + ) + result = gateway.tools.search("search the web") request = sent_request(gateway) assert request.method == "POST" - assert request.url.endswith("/mcp/meta") - assert request.headers["Content-Type"] == "application/json" - assert request.headers["MCP-Protocol-Version"] == MCP_PROTOCOL_VERSION - assert request.headers["Accept"] == "application/json, text/event-stream" - + assert request.url.endswith("/tools/search") + assert request.headers[SESSION_ID_HEADER] == TEST_SESSION_URN payload = sent_payload(gateway) - assert payload["jsonrpc"] == "2.0" - assert payload["method"] == "tools/list" - assert isinstance(payload["id"], int) - - assert tools[0].name == "action_search" + assert payload["queries"] == [{"use_case": "search the web"}] + assert result.results[0].use_case == "x" -def test_list_tools_include_all_hits_concrete_endpoint(): - gateway = make_gateway( - [FakeResponse(200, jsonrpc_result({"tools": [{"name": "web_search"}]}))] +def test_invoke_posts_rest_envelope(): + gateway = make_gateway([FakeResponse(200, invoke_envelope(output={"ok": True}))]) + result = gateway.tools.invoke( + [{"tool": "web_search", "arguments": {"query": "do"}}] ) - gateway.tools.list(include_all=True) - assert sent_request(gateway).url.endswith("/mcp") + assert sent_request(gateway).url.endswith("/tools/invoke") + assert result.success_count == 1 -def test_call_tool_prefers_structured_content(): - structured = {"stdout": "hi", "exit_code": 0} +def test_code_execute_posts_rest(): gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result(structured, text="hi")))] + [FakeResponse(200, tool_result({"stdout": "hi", "exit_code": 0}))] ) result = gateway.code.execute("print('hi')") + assert sent_request(gateway).url.endswith("/code/execute") assert result.stdout == "hi" assert result.exit_code == 0 -def test_call_tool_falls_back_to_content_text(): +def test_concrete_call_routes_through_invoke(): gateway = make_gateway( - [ - FakeResponse( - 200, - jsonrpc_result( - {"isError": False, "content": [{"type": "text", "text": "plain"}]} - ), - ) - ] + [FakeResponse(200, invoke_envelope(output={"answer": 42}))] ) - result = gateway.tools.call("web_fetch", {"url": "https://example.com"}) - assert result == "plain" + result = gateway.tools.call("web_search", {"query": "x"}) + assert sent_request(gateway).url.endswith("/tools/invoke") + assert result.answer == 42 -def test_call_tool_content_text_parsed_as_json_when_possible(): +def test_failed_tool_result_raises(): gateway = make_gateway( [ FakeResponse( 200, - jsonrpc_result( - { - "isError": False, - "content": [{"type": "text", "text": '{"answer": 42}'}], + tool_result( + error={ + "class": "rate_limited", + "message": "slow down", + "retriable": True, + "recovery_hint": "backoff", } ), ) ] ) - result = gateway.tools.call("web_fetch", {"url": "https://example.com"}) - assert result.answer == 42 - - -def test_is_error_raises_gateway_tool_error_with_taxonomy(): - structured = { - "invocation_id": "inv_1", - "error": { - "class": "rate_limited", - "message": "slow down", - "retriable": True, - "recovery_hint": "backoff", - }, - } - gateway = make_gateway( - [FakeResponse(200, jsonrpc_result(call_result(structured, is_error=True)))] - ) with pytest.raises(GatewayToolError) as excinfo: - gateway.tools.call("web_search", {"query": "x"}) + gateway.code.execute("1") err = excinfo.value assert err.error_class == "rate_limited" assert err.retriable is True assert err.recovery_hint == "backoff" - assert err.invocation_id == "inv_1" - - -def test_is_error_without_structure_uses_content_text(): - gateway = make_gateway( - [ - FakeResponse( - 200, - jsonrpc_result( - {"isError": True, "content": [{"type": "text", "text": "boom"}]} - ), - ) - ] - ) - with pytest.raises(GatewayToolError, match="boom"): - gateway.tools.call("web_search", {"query": "x"}) - - -def test_jsonrpc_error_raises_protocol_error(): - gateway = make_gateway( - [FakeResponse(200, jsonrpc_error(-32601, "method not found"))] - ) - with pytest.raises(GatewayProtocolError) as excinfo: - gateway.tools.list() - assert excinfo.value.code == -32601 def test_non_json_body_raises_protocol_error(): gateway = make_gateway([FakeResponse(200, "nope")]) with pytest.raises(GatewayProtocolError, match="non-JSON"): - gateway.tools.list() + gateway.tools.list(include_all=True) @pytest.mark.parametrize( @@ -167,24 +156,72 @@ def test_non_json_body_raises_protocol_error(): def test_http_errors_are_mapped(status, exc): gateway = make_gateway([FakeResponse(status, {"type": "invalid_request"})]) with pytest.raises(exc): - gateway.tools.list() + gateway.tools.list(include_all=True) def test_412_message_mentions_release_gate(): gateway = make_gateway([FakeResponse(412, "nope")]) with pytest.raises(HttpResponseError, match="Action Infra release"): + gateway.tools.list(include_all=True) + + +def test_session_mcp_url_uses_uuid_from_urn(): + session_uuid = "3a12f86f-ef5c-41e3-a951-2b7a933e151d" + url = session_mcp_url( + TEST_GATEWAY_URL, + f"do:managed_agents_session:{session_uuid}", + ) + assert url == f"https://actions.do-ai-test.run/mcp/session/{session_uuid}" + + +def test_mcp_transport_still_works_with_session_header(): + parent = make_parent( + [FakeResponse(200, jsonrpc_result({"tools": [{"name": "action_search"}]}))] + ) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = MCPTransport(proxy, session_id=TEST_SESSION_URN) + gateway = GatewayResources( + parent, + gateway_endpoint=TEST_GATEWAY_URL, + transport=transport, + ) + tools = gateway.tools.list() + request = sent_request(gateway) + assert request.url.endswith("/mcp/meta") + assert request.headers[SESSION_ID_HEADER] == TEST_SESSION_URN + assert tools[0].name == "action_search" + + +def test_mcp_jsonrpc_error_raises_protocol_error(): + parent = make_parent([FakeResponse(200, jsonrpc_error(-32601, "method not found"))]) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = MCPTransport(proxy, session_id=TEST_SESSION_URN) + gateway = GatewayResources( + parent, gateway_endpoint=TEST_GATEWAY_URL, transport=transport + ) + with pytest.raises(GatewayProtocolError) as excinfo: gateway.tools.list() + assert excinfo.value.code == -32601 -def test_request_ids_increment(): - gateway = make_gateway( - [ - FakeResponse(200, jsonrpc_result({"tools": []}, rpc_id=1)), - FakeResponse(200, jsonrpc_result({"tools": []}, rpc_id=2)), - ] +def test_mcp_is_error_raises_gateway_tool_error(): + structured = { + "invocation_id": "inv_1", + "error": { + "class": "rate_limited", + "message": "slow down", + "retriable": True, + "recovery_hint": "backoff", + }, + } + parent = make_parent( + [FakeResponse(200, jsonrpc_result(call_result(structured, is_error=True)))] ) - gateway.tools.list() - gateway.tools.list() - first = sent_payload(gateway, 0) - second = sent_payload(gateway, 1) - assert second["id"] == first["id"] + 1 + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = MCPTransport(proxy, session_id=TEST_SESSION_URN) + gateway = GatewayResources( + parent, gateway_endpoint=TEST_GATEWAY_URL, transport=transport + ) + with pytest.raises(GatewayToolError) as excinfo: + gateway.tools.call("web_search", {"query": "x"}) + assert excinfo.value.invocation_id == "inv_1" From 7ad0ebcd866056904f12f40d2488ae993b87db9f Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Mon, 20 Jul 2026 17:13:07 -0700 Subject: [PATCH 05/19] Fix test lint formatting --- tests/gateway/test_providers.py | 4 +--- tests/gateway/test_transport.py | 8 ++------ tests/integration/test_droplets.py | 1 - 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/gateway/test_providers.py b/tests/gateway/test_providers.py index db36b668..7581c228 100644 --- a/tests/gateway/test_providers.py +++ b/tests/gateway/test_providers.py @@ -338,9 +338,7 @@ def test_handle_tool_calls_batches_concrete_tools(): assert sent_request(gateway).url.endswith("/tools/invoke") payload = sent_payload(gateway) assert payload["rationale"] == "why not" - assert payload["tools"] == [ - {"tool": "web_search", "arguments": {"query": "do"}} - ] + assert payload["tools"] == [{"tool": "web_search", "arguments": {"query": "do"}}] assert messages[0]["role"] == "tool" assert messages[0]["tool_call_id"] == "call_1" diff --git a/tests/gateway/test_transport.py b/tests/gateway/test_transport.py index 2a306835..1343aca2 100644 --- a/tests/gateway/test_transport.py +++ b/tests/gateway/test_transport.py @@ -56,9 +56,7 @@ def pipeline_calls(gateway) -> int: def test_list_tools_include_all_hits_rest_catalog(): - gateway = make_gateway( - [FakeResponse(200, {"tools": [{"name": "web_search"}]})] - ) + gateway = make_gateway([FakeResponse(200, {"tools": [{"name": "web_search"}]})]) tools = gateway.tools.list(include_all=True) request = sent_request(gateway) assert request.method == "GET" @@ -106,9 +104,7 @@ def test_code_execute_posts_rest(): def test_concrete_call_routes_through_invoke(): - gateway = make_gateway( - [FakeResponse(200, invoke_envelope(output={"answer": 42}))] - ) + gateway = make_gateway([FakeResponse(200, invoke_envelope(output={"answer": 42}))]) result = gateway.tools.call("web_search", {"query": "x"}) assert sent_request(gateway).url.endswith("/tools/invoke") assert result.answer == 42 diff --git a/tests/integration/test_droplets.py b/tests/integration/test_droplets.py index 9b490027..d3bab0b6 100644 --- a/tests/integration/test_droplets.py +++ b/tests/integration/test_droplets.py @@ -41,7 +41,6 @@ def test_droplet_attach_volume(integration_client: Client, public_key: bytes): } with shared.with_test_volume(integration_client, **volume_req) as volume: - vol_attach_resp = integration_client.volume_actions.post_by_id( volume["volume"]["id"], {"type": "attach", "droplet_id": droplet["droplet"]["id"]}, From 5bc5ea7db6c1f2fc37c1e1c00383deac1486c810 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Mon, 20 Jul 2026 17:17:03 -0700 Subject: [PATCH 06/19] Fix action gateway session route --- docs/gateway-sdk-experience.md | 2 +- src/pydo/aio/gateway/session.py | 4 ++-- src/pydo/gateway/__init__.py | 4 ++-- src/pydo/gateway/session.py | 4 ++-- src/pydo/gateway/transport.py | 6 +++--- tests/gateway/test_session.py | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/gateway-sdk-experience.md b/docs/gateway-sdk-experience.md index 9813b9e4..fb2e8b21 100644 --- a/docs/gateway-sdk-experience.md +++ b/docs/gateway-sdk-experience.md @@ -40,7 +40,7 @@ session = client.sessions.create( ) ``` -Session create hits `POST /v2/sessions` on `api.digitalocean.com`. Tool calls go to the gateway host (`https://actions.do-ai.run` by default; override with `gateway_endpoint=` or `PYDO_GATEWAY_ENDPOINT`). +Session create hits `POST /v2/action-gateway/sessions` on `api.digitalocean.com`. Tool calls go to the gateway host (`https://actions.do-ai.run` by default; override with `gateway_endpoint=` or `PYDO_GATEWAY_ENDPOINT`). `session.url` is the session-pinned MCP URL for external MCP clients: diff --git a/src/pydo/aio/gateway/session.py b/src/pydo/aio/gateway/session.py index ecb90398..7494409d 100644 --- a/src/pydo/aio/gateway/session.py +++ b/src/pydo/aio/gateway/session.py @@ -31,7 +31,7 @@ async_execute_tool_calls, ) -_SESSIONS_PATH = "/v2/sessions" +_SESSIONS_PATH = "/v2/action-gateway/sessions" def _pick(data: Dict[str, Any], *keys: str) -> Any: @@ -102,7 +102,7 @@ def __repr__(self) -> str: # pragma: no cover class AsyncSessionsOperations: - """Async create via ``POST /v2/sessions`` on the DO API.""" + """Async create via ``POST /v2/action-gateway/sessions`` on the DO API.""" def __init__( self, diff --git a/src/pydo/gateway/__init__.py b/src/pydo/gateway/__init__.py index 23a30f69..6325552b 100644 --- a/src/pydo/gateway/__init__.py +++ b/src/pydo/gateway/__init__.py @@ -5,8 +5,8 @@ """Action Gateway API — hand-written; preserved across ``make generate``. Session-first surface: create a session on the DigitalOcean API -(``POST /v2/sessions``), then discover/invoke tools and run code over the -gateway REST endpoints with ``X-Session-Id``. Composio-style providers make +(``POST /v2/action-gateway/sessions``), then discover/invoke tools and run code +over the gateway REST endpoints with ``X-Session-Id``. Composio-style providers make session tools plug into pydo inference surfaces (chat completions, messages, responses). """ diff --git a/src/pydo/gateway/session.py b/src/pydo/gateway/session.py index 48a197cd..7ab9f22e 100644 --- a/src/pydo/gateway/session.py +++ b/src/pydo/gateway/session.py @@ -26,7 +26,7 @@ session_mcp_url, ) -_SESSIONS_PATH = "/v2/sessions" +_SESSIONS_PATH = "/v2/action-gateway/sessions" _DEFAULT_POLICY: Dict[str, Any] = {"defaultAction": "allow", "rules": []} @@ -137,7 +137,7 @@ def __repr__(self) -> str: # pragma: no cover - debug aid class SessionsOperations: - """Create Action Gateway sessions via ``POST /v2/sessions`` on the DO API.""" + """Create sessions via ``POST /v2/action-gateway/sessions`` on the DO API.""" def __init__( self, diff --git a/src/pydo/gateway/transport.py b/src/pydo/gateway/transport.py index 33a6913c..8a470fb4 100644 --- a/src/pydo/gateway/transport.py +++ b/src/pydo/gateway/transport.py @@ -183,12 +183,12 @@ def _raise_gateway_http_error(response: Any) -> None: "team is not enabled for the Action Infra release " f"(412 Precondition Failed): {message}" ) - if response.status_code == 404 and "/v2/sessions" in ( + if response.status_code == 404 and "/v2/action-gateway/sessions" in ( getattr(getattr(response, "request", None), "url", "") or "" ): message = ( - "session create returned 404 — is POST /v2/sessions available on " - f"this API endpoint? {message}" + "session create returned 404 — is POST /v2/action-gateway/sessions " + f"available on this API endpoint? {message}" ) raise HttpResponseError(message=message, response=response) diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 8507222f..164da5f6 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -79,7 +79,7 @@ def test_sessions_create_posts_to_do_api_and_binds_rest(): create_req = parent._client._pipeline.calls[0].request assert create_req.method == "POST" - assert create_req.url.endswith("/v2/sessions") + assert create_req.url.endswith("/v2/action-gateway/sessions") body = json.loads(create_req.content) assert body["end_user_id"] == "user-123" assert json.loads(body["policy_json"]) == { From d73fcf01bf8367c8eed3ccc1bb8317e160353cdf Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Mon, 20 Jul 2026 18:19:37 -0700 Subject: [PATCH 07/19] Finalize action gateway SDK compatibility --- src/pydo/gateway/custom_models.py | 9 +++-- src/pydo/gateway/transport.py | 60 ++++++++++++++++++++++------- tests/gateway/conftest.py | 5 +++ tests/gateway/test_async_gateway.py | 29 ++++++++++++++ tests/gateway/test_session.py | 15 ++++++++ tests/gateway/test_transport.py | 53 +++++++++++++++++++++++-- 6 files changed, 151 insertions(+), 20 deletions(-) diff --git a/src/pydo/gateway/custom_models.py b/src/pydo/gateway/custom_models.py index 9ff64e2f..0fb568fa 100644 --- a/src/pydo/gateway/custom_models.py +++ b/src/pydo/gateway/custom_models.py @@ -29,20 +29,23 @@ class ToolErrorClass: UNAUTHORIZED = "unauthorized" FORBIDDEN = "forbidden" RATE_LIMITED = "rate_limited" + NOT_FOUND = "not_found" TIMEOUT = "timeout" UPSTREAM_ERROR = "upstream_error" OUTPUT_TOO_LARGE = "output_too_large" EXECUTION_FAILED = "execution_failed" UNAVAILABLE = "unavailable" + CANCELED = "canceled" class RecoveryHint: """Machine-routable hint on how a caller should recover from a failure.""" FIX_ARGS = "fix_args" - RETRY = "retry" - BACKOFF = "backoff" - ESCALATE = "escalate" + REFRESH_AUTH = "refresh_auth" + RETRY_LATER = "retry_later" + NARROW_OUTPUT = "narrow_output" + CONTACT_SUPPORT = "contact_support" class GatewayToolError(RuntimeError): diff --git a/src/pydo/gateway/transport.py b/src/pydo/gateway/transport.py index 8a470fb4..d09d0f27 100644 --- a/src/pydo/gateway/transport.py +++ b/src/pydo/gateway/transport.py @@ -24,7 +24,6 @@ ResourceExistsError, ResourceNotFoundError, ResourceNotModifiedError, - map_error, ) from azure.core.rest import HttpRequest @@ -50,6 +49,7 @@ def resolve_gateway_base_url(explicit: Optional[str] = None) -> str: url = f"https://{url}" return url + _ERROR_MAP = { 401: ClientAuthenticationError, 404: ResourceNotFoundError, @@ -129,9 +129,13 @@ def resolve_gateway_base_url(explicit: Optional[str] = None) -> str: "tool_slug": {"type": "string"}, "arguments": {"type": "object"}, }, + "anyOf": [ + {"required": ["tool"]}, + {"required": ["tool_slug"]}, + ], }, }, - "rationale": {"type": "string"}, + "rationale": {"type": "string", "maxLength": 512}, }, "required": ["tools"], }, @@ -150,6 +154,10 @@ def resolve_gateway_base_url(explicit: Optional[str] = None) -> str: "code_to_execute": {"type": "string"}, "thought": {"type": "string"}, }, + "anyOf": [ + {"required": ["code"]}, + {"required": ["code_to_execute"]}, + ], }, }, ] @@ -157,11 +165,6 @@ def resolve_gateway_base_url(explicit: Optional[str] = None) -> str: def _response_body_text(response: Any) -> str: try: - if hasattr(response, "read"): - try: - response.read() - except Exception: # noqa: BLE001 - pass body = response.text() if hasattr(response, "text") else response.body() if isinstance(body, bytes): body = body.decode("utf-8", errors="replace") @@ -172,11 +175,6 @@ def _response_body_text(response: Any) -> str: def _raise_gateway_http_error(response: Any) -> None: body = _response_body_text(response) - map_error( - status_code=response.status_code, - response=response, - error_map=_ERROR_MAP, - ) message = body.strip() or getattr(response, "reason", None) or "request failed" if response.status_code == 412: message = ( @@ -190,6 +188,13 @@ def _raise_gateway_http_error(response: Any) -> None: "session create returned 404 — is POST /v2/action-gateway/sessions " f"available on this API endpoint? {message}" ) + error_type = _ERROR_MAP.get(response.status_code) + if error_type: + raise error_type( + message=message, + response=response, + error_format=lambda _body: None, + ) raise HttpResponseError(message=message, response=response) @@ -248,6 +253,33 @@ def _parse_json_body(body: Any) -> Any: def _parse_jsonrpc(body: Any) -> Dict[str, Any]: + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + if isinstance(body, str) and any( + line.startswith("data:") for line in body.splitlines() + ): + events = [] + data_lines = [] + for line in body.splitlines(): + if not line: + if data_lines: + events.append("\n".join(data_lines)) + data_lines = [] + continue + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + if data_lines: + events.append("\n".join(data_lines)) + for event in events: + try: + candidate = _parse_json_body(event) + except GatewayProtocolError: + continue + if isinstance(candidate, dict) and ( + "result" in candidate or "error" in candidate + ): + body = candidate + break envelope = _parse_json_body(body) if not isinstance(envelope, dict): raise GatewayProtocolError( @@ -365,7 +397,9 @@ def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: # Invoke returns the batch envelope directly (not ToolResult). return _wrap(self._request("POST", _REST_INVOKE_PATH, arguments)) if name == META_CODE or (meta and name == META_CODE): - return _unwrap_tool_result(self._request("POST", _REST_CODE_PATH, arguments)) + return _unwrap_tool_result( + self._request("POST", _REST_CODE_PATH, arguments) + ) # Concrete catalog tool → single-item invoke. envelope = self._request( "POST", diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index 21719b2c..a38f4df6 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -49,7 +49,12 @@ def close(self) -> None: class AsyncFakeResponse(FakeResponse): + def __init__(self, status_code: int, body: Any = None): + super().__init__(status_code, body) + self.read_calls = 0 + async def read(self) -> bytes: # pylint: disable=invalid-overridden-method + self.read_calls += 1 return self._body_bytes diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index 87dc8634..09e94456 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -11,15 +11,20 @@ import json import pytest +from azure.core.exceptions import HttpResponseError +from pydo.aio.gateway import AsyncGatewayResources, AsyncMCPTransport +from pydo.custom_extensions import _BaseURLProxy from pydo.gateway import ChatCompletionsProvider, GatewayToolError, SESSION_ID_HEADER from .conftest import ( + TEST_GATEWAY_URL, TEST_SESSION_URN, AsyncFakeResponse, chat_tool_response, invoke_envelope, make_async_gateway, + make_async_parent, tool_result, ) @@ -74,6 +79,30 @@ def test_code_execute_failure_raises(): _run(gateway.code.execute("1/0")) +def test_http_error_reads_async_response_once(): + response = AsyncFakeResponse(400, "bad request") + gateway = make_async_gateway([response]) + with pytest.raises(HttpResponseError, match="bad request"): + _run(gateway.tools.list(include_all=True)) + assert response.read_calls == 1 + + +def test_mcp_transport_parses_sse_response(): + response = ( + "event: message\n" + 'data: {"jsonrpc":"2.0","id":1,"result":{"tools":' + '[{"name":"action_search"}]}}\n\n' + ) + parent = make_async_parent([AsyncFakeResponse(200, response)]) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + gateway = AsyncGatewayResources( + parent, + gateway_endpoint=TEST_GATEWAY_URL, + transport=AsyncMCPTransport(proxy, session_id=TEST_SESSION_URN), + ) + assert _run(gateway.tools.list())[0].name == "action_search" + + def test_tools_callable_and_handle_tool_calls(): envelope = invoke_envelope(output={"ok": True}) gateway = make_async_gateway( diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 164da5f6..219bc0d6 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -8,8 +8,10 @@ from __future__ import annotations import json +from types import SimpleNamespace import pytest +from azure.core.exceptions import ResourceNotFoundError from pydo.gateway import ( SESSION_ID_HEADER, @@ -67,6 +69,19 @@ def test_sessions_create_requires_end_user_id(): ops.create("") +def test_sessions_create_404_has_route_diagnostic(): + response = FakeResponse( + 404, + {"id": "not_found", "message": "Your request could not be routed."}, + ) + response.request = SimpleNamespace( + url="https://api.digitalocean.com/v2/action-gateway/sessions" + ) + ops = SessionsOperations(make_parent([response]), gateway_endpoint=TEST_GATEWAY_URL) + with pytest.raises(ResourceNotFoundError, match="session create returned 404"): + ops.create("user-123") + + def test_sessions_create_posts_to_do_api_and_binds_rest(): parent = make_parent( [ diff --git a/tests/gateway/test_transport.py b/tests/gateway/test_transport.py index 1343aca2..723e7601 100644 --- a/tests/gateway/test_transport.py +++ b/tests/gateway/test_transport.py @@ -20,9 +20,11 @@ GatewayResources, GatewayToolError, MCPTransport, + RecoveryHint, SESSION_ID_HEADER, + ToolErrorClass, ) -from pydo.gateway.transport import session_mcp_url +from pydo.gateway.transport import _META_TOOL_DEFINITIONS, session_mcp_url from .conftest import ( TEST_GATEWAY_URL, @@ -51,6 +53,29 @@ def test_list_meta_tools_is_local_no_network(): assert pipeline_calls(gateway) == 0 +def test_gateway_constants_match_server_contract(): + assert ToolErrorClass.NOT_FOUND == "not_found" + assert ToolErrorClass.CANCELED == "canceled" + assert RecoveryHint.REFRESH_AUTH == "refresh_auth" + assert RecoveryHint.RETRY_LATER == "retry_later" + assert RecoveryHint.NARROW_OUTPUT == "narrow_output" + assert RecoveryHint.CONTACT_SUPPORT == "contact_support" + + +def test_meta_schemas_match_server_constraints(): + definitions = {tool["name"]: tool for tool in _META_TOOL_DEFINITIONS} + invoke_schema = definitions["action_invoke"]["inputSchema"] + assert invoke_schema["properties"]["rationale"]["maxLength"] == 512 + assert invoke_schema["properties"]["tools"]["items"]["anyOf"] == [ + {"required": ["tool"]}, + {"required": ["tool_slug"]}, + ] + assert definitions["action_code"]["inputSchema"]["anyOf"] == [ + {"required": ["code"]}, + {"required": ["code_to_execute"]}, + ] + + def pipeline_calls(gateway) -> int: return len(gateway._transport._client._original._pipeline.calls) @@ -120,7 +145,7 @@ def test_failed_tool_result_raises(): "class": "rate_limited", "message": "slow down", "retriable": True, - "recovery_hint": "backoff", + "recovery_hint": "retry_later", } ), ) @@ -131,7 +156,7 @@ def test_failed_tool_result_raises(): err = excinfo.value assert err.error_class == "rate_limited" assert err.retriable is True - assert err.recovery_hint == "backoff" + assert err.recovery_hint == "retry_later" def test_non_json_body_raises_protocol_error(): @@ -188,6 +213,26 @@ def test_mcp_transport_still_works_with_session_header(): assert tools[0].name == "action_search" +def test_mcp_transport_parses_sse_response(): + response = ( + ": heartbeat\n\n" + "event: message\n" + 'data: {"jsonrpc":"2.0","method":"notifications/progress"}\n\n' + "event: message\n" + 'data: {"jsonrpc":"2.0","id":1,"result":{"tools":' + '[{"name":"action_search"}]}}\n\n' + "data: [DONE]\n\n" + ) + parent = make_parent([FakeResponse(200, response)]) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + gateway = GatewayResources( + parent, + gateway_endpoint=TEST_GATEWAY_URL, + transport=MCPTransport(proxy, session_id=TEST_SESSION_URN), + ) + assert gateway.tools.list()[0].name == "action_search" + + def test_mcp_jsonrpc_error_raises_protocol_error(): parent = make_parent([FakeResponse(200, jsonrpc_error(-32601, "method not found"))]) proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) @@ -207,7 +252,7 @@ def test_mcp_is_error_raises_gateway_tool_error(): "class": "rate_limited", "message": "slow down", "retriable": True, - "recovery_hint": "backoff", + "recovery_hint": "retry_later", }, } parent = make_parent( From 00fef69207590199979cf47efaaf82b389574163 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Mon, 27 Jul 2026 10:21:06 -0500 Subject: [PATCH 08/19] fix --- docs/gateway-sdk-experience.md | 116 +- examples/gateway/async_invoke_tools.py | 10 +- examples/gateway/create_toolbelt.py | 21 + examples/gateway/execute_code.py | 10 +- examples/gateway/function_calling_loop.py | 41 +- examples/gateway/invoke_tools.py | 10 +- examples/gateway/list_tools.py | 10 +- examples/gateway/messages_tool_use.py | 10 +- examples/gateway/responses_tool_use.py | 34 + examples/gateway/search_tools.py | 10 +- examples/gateway/toolbelt_policy.py | 28 + openapi/README.md | 19 + openapi/action-gateway-toolbelts.patch | 518 +++++++ src/pydo/_client.py | 14 +- src/pydo/action_gateway/__init__.py | 28 +- src/pydo/action_gateway/aio/__init__.py | 27 +- src/pydo/aio/_client.py | 14 +- src/pydo/aio/gateway/__init__.py | 8 +- src/pydo/aio/gateway/custom_operations.py | 40 +- src/pydo/aio/gateway/session.py | 60 +- src/pydo/aio/operations/__init__.py | 2 + src/pydo/aio/operations/_operations.py | 1264 +++++++++++++++++ src/pydo/gateway/__init__.py | 14 +- src/pydo/gateway/custom_models.py | 13 + src/pydo/gateway/custom_operations.py | 4 +- src/pydo/gateway/providers.py | 7 +- src/pydo/gateway/session.py | 72 +- src/pydo/gateway/transport.py | 45 +- src/pydo/operations/__init__.py | 2 + src/pydo/operations/_operations.py | 1413 +++++++++++++++++++ tests/gateway/conftest.py | 19 +- tests/gateway/test_action_gateway_client.py | 70 +- tests/gateway/test_async_gateway.py | 51 +- tests/gateway/test_session.py | 51 +- tests/gateway/test_transport.py | 20 +- 35 files changed, 3853 insertions(+), 222 deletions(-) create mode 100644 examples/gateway/create_toolbelt.py create mode 100644 examples/gateway/responses_tool_use.py create mode 100644 examples/gateway/toolbelt_policy.py create mode 100644 openapi/README.md create mode 100644 openapi/action-gateway-toolbelts.patch diff --git a/docs/gateway-sdk-experience.md b/docs/gateway-sdk-experience.md index fb2e8b21..febcadc3 100644 --- a/docs/gateway-sdk-experience.md +++ b/docs/gateway-sdk-experience.md @@ -15,32 +15,32 @@ export DIGITALOCEAN_TOKEN=... ``` ```python -from pydo.action_gateway import Client +from pydo.action_gateway import ActionGatewayClient -client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) -session = client.sessions.create( - end_user_id="user-123", # required +session = client.session.create( + actor_id="user-123", # required # permissions optional — defaults to allow-all ) ``` -`end_user_id` is required. If you omit `permissions`, the SDK creates a default policy of `{"defaultAction": "allow", "rules": []}`. Optional permissions: +`actor_id` is required. If you omit `permissions`, the SDK creates a default policy of `{"defaultAction": "allow"}`. Optional permissions: ```python -session = client.sessions.create( - end_user_id="user-123", +session = client.session.create( + actor_id="user-123", permissions={ "default_action": "ask", "rules": [ - {"toolbelt": "read-only@1.2.3", "action": "allow"}, + {"tool": "toolbelt:read-only@1.2.3", "action": "allow"}, {"tool": "gmail", "action": "allow"}, ], }, ) ``` -Session create hits `POST /v2/action-gateway/sessions` on `api.digitalocean.com`. Tool calls go to the gateway host (`https://actions.do-ai.run` by default; override with `gateway_endpoint=` or `PYDO_GATEWAY_ENDPOINT`). +Session create hits `POST /v2/action-gateway/sessions` on `api.digitalocean.com` with `name`, `policy`, and `actor_id`. The same actor is sent as `X-Actor-Id` on gateway requests. Tool calls go to the gateway host (`https://actions.do-ai.run` by default; override with `gateway_endpoint=` or `PYDO_GATEWAY_ENDPOINT`). `session.url` is the session-pinned MCP URL for external MCP clients: @@ -57,19 +57,19 @@ results = session.tools.search("search the web for recent news") catalog = session.tools.list(include_all=True) output = session.tools.invoke_one( - "EXA_SEARCH", - {"query": "DigitalOcean news", "num_results": 5}, + "exa_web_search", + {"query": "DigitalOcean news", "max_results": 5}, ) envelope = session.tools.invoke([ - {"tool": "EXA_SEARCH", "arguments": {"query": "DigitalOcean news"}}, - {"tool": "HACKERNEWS_GET_TODAY_STORIES", "arguments": {}}, + {"tool": "exa_web_search", "arguments": {"query": "DigitalOcean news"}}, + {"tool": "exa_web_fetch", "arguments": {"url": "https://www.digitalocean.com"}}, ]) result = session.code.execute("print(sum(range(10)))") ``` -These map to REST: `POST /tools/search`, `POST /tools/invoke`, `POST /code/execute`, always with `X-Session-Id`. +These calls use JSON-RPC over the `mcpUrl` returned by session creation, with `X-Session-Id` and `X-Actor-Id`. --- @@ -81,10 +81,10 @@ These map to REST: `POST /tools/search`, `POST /tools/invoke`, `POST /code/execu ### Chat Completions ```python -from pydo.action_gateway import Client +from pydo.action_gateway import ActionGatewayClient -client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) -session = client.sessions.create(end_user_id="user-123") +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create(actor_id="user-123") tools = session.tools() messages = [{"role": "user", "content": @@ -109,13 +109,13 @@ print(message["content"]) ### Messages API ```python -from pydo.action_gateway import Client, MessagesProvider +from pydo.action_gateway import ActionGatewayClient, MessagesProvider -client = Client( +client = ActionGatewayClient( token=os.environ["DIGITALOCEAN_TOKEN"], gateway_provider=MessagesProvider(), ) -session = client.sessions.create(end_user_id="user-123") +session = client.session.create(actor_id="user-123") tools = session.tools() # ... same loop with client.messages.create and session.handle_tool_calls @@ -129,19 +129,83 @@ tools = session.tools() ```python tools = session.tools(include_all=True) -tools = session.tools(names=["EXA_SEARCH"]) +tools = session.tools(names=["exa_web_search"]) tools = session.tools(search="post a message to slack", limit=5) ``` --- -## 5. Async +## 5. Toolbelts and policies + +Create a versioned toolbelt from provider-qualified tool names: + +```python +toolbelt = client.create_toolbelt( + name="search-toolbelt", + tools=["exa_web_search", "exa_web_fetch"], +) +print(toolbelt.ref) # search-toolbelt@1 +``` + +Toolbelts are public DigitalOcean API resources, so the base CRUD surface is +generated from the public OpenAPI specification under `client.toolbelts`: + +```python +client.toolbelts.list(status="active") +client.toolbelts.get("search-toolbelt", version="1") +client.toolbelts.add_tools("search-toolbelt", {"tools": ["jira_create_issue"]}) +client.toolbelts.delete_tools("search-toolbelt", {"tools": ["exa_web_fetch"]}) +client.toolbelts.delete("search-toolbelt") +``` + +`client.create_toolbelt(...)` is the Action Gateway convenience wrapper around +the generated `client.toolbelts.create(body=...)` operation. + +Pin that version in a session policy: + +```python +session = client.session.create( + actor_id="user-123", + permissions={ + "default_action": "ask", + "rules": [ + {"tool": f"toolbelt:{toolbelt.ref}", "action": "allow"}, + ], + }, +) +``` + +Toolbelt creation maps to `POST /v2/action-gateway/toolbelts`. The response exposes both `toolbelt.reference` and the shorter `toolbelt.ref` alias. + +--- + +## 6. Responses API + +```python +from pydo.action_gateway import ActionGatewayClient, ResponsesProvider + +client = ActionGatewayClient( + token=os.environ["DIGITALOCEAN_TOKEN"], + gateway_provider=ResponsesProvider(), +) +session = client.session.create(actor_id="user-123") +response = client.responses.create( + model="openai-gpt-4o", + input="What DigitalOcean Droplet sizes are available in NYC3?", + tools=session.tools(), +) +tool_outputs = session.handle_tool_calls(response) +``` + +--- + +## 7. Async ```python -from pydo.action_gateway.aio import Client +from pydo.action_gateway.aio import ActionGatewayClient -async with Client(token=token) as client: - session = await client.sessions.create(end_user_id="user-123") +async with ActionGatewayClient(token=token) as client: + session = await client.session.create(actor_id="user-123") tools = await session.tools() response = await client.chat.completions.create(..., tools=tools) messages.extend(await session.handle_tool_calls(response)) @@ -149,7 +213,7 @@ async with Client(token=token) as client: --- -## 6. Design notes +## 8. Design notes - **Session-first.** Bare gateway calls without a session are unsupported. - **REST for SDK execution.** MCP remains available via `session.url` for external clients. diff --git a/examples/gateway/async_invoke_tools.py b/examples/gateway/async_invoke_tools.py index 473ffae7..e4026b8d 100644 --- a/examples/gateway/async_invoke_tools.py +++ b/examples/gateway/async_invoke_tools.py @@ -5,19 +5,19 @@ Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run - END_USER_ID + ACTOR_ID """ import asyncio import os -from pydo.action_gateway.aio import Client +from pydo.action_gateway.aio import ActionGatewayClient async def main() -> None: - client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) - session = await client.sessions.create( - end_user_id=os.environ.get("END_USER_ID", "example-user"), + client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) + session = await client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), ) tools = await session.tools.list(include_all=True) diff --git a/examples/gateway/create_toolbelt.py b/examples/gateway/create_toolbelt.py new file mode 100644 index 00000000..bca6edb7 --- /dev/null +++ b/examples/gateway/create_toolbelt.py @@ -0,0 +1,21 @@ +"""Create a versioned Action Gateway toolbelt. + +Required env: + DIGITALOCEAN_TOKEN +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) + +toolbelt = client.create_toolbelt( + name="search-toolbelt", + tools=[ + "exa_web_search", + "exa_web_fetch", + ], +) + +print(toolbelt.ref) diff --git a/examples/gateway/execute_code.py b/examples/gateway/execute_code.py index aa61cabb..a12a3739 100644 --- a/examples/gateway/execute_code.py +++ b/examples/gateway/execute_code.py @@ -5,16 +5,16 @@ Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run - END_USER_ID + ACTOR_ID """ import os -from pydo.action_gateway import Client +from pydo.action_gateway import ActionGatewayClient -client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) -session = client.sessions.create( - end_user_id=os.environ.get("END_USER_ID", "example-user"), +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), ) result = session.code.execute( diff --git a/examples/gateway/function_calling_loop.py b/examples/gateway/function_calling_loop.py index 6100d7a8..54861c7b 100644 --- a/examples/gateway/function_calling_loop.py +++ b/examples/gateway/function_calling_loop.py @@ -5,42 +5,63 @@ Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run - END_USER_ID + ACTOR_ID MODEL PROMPT """ +import json import os -from pydo.action_gateway import Client +from pydo.action_gateway import ActionGatewayClient -client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) -session = client.sessions.create( - end_user_id=os.environ.get("END_USER_ID", "example-user"), +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), ) -model = os.environ.get("MODEL", "openai-gpt-4o") -prompt = os.environ.get( - "PROMPT", "Find the latest news about DigitalOcean and summarize it." -) +model = os.environ.get("MODEL", "openai-gpt-5.4") +prompt = os.environ.get("PROMPT", "Search the web for information on DigitalOcean.") tools = session.tools() messages = [{"role": "user", "content": prompt}] +tool_choice = "required" while True: response = client.chat.completions.create( model=model, messages=messages, tools=tools, + tool_choice=tool_choice, ) message = response.choices[0].message if not message.get("tool_calls"): break messages.append(dict(message)) + tool_names = {} + for tool_call in message["tool_calls"]: + function = tool_call["function"] + tool_names[tool_call["id"]] = function["name"] + print(f"\n[tool call: {function['name']} id={tool_call['id']}]") + try: + arguments = json.loads(function["arguments"]) + print(json.dumps(arguments, indent=2)) + except (TypeError, ValueError): + print(function["arguments"]) + tool_messages = session.handle_tool_calls(response) + if any(name != "action_search" for name in tool_names.values()): + tool_choice = "auto" for tool_message in tool_messages: - print(f"[tool result] {str(tool_message['content'])[:120]}") + tool_call_id = tool_message.get("tool_call_id", "unknown") + tool_name = tool_names.get(tool_call_id, "unknown") + print(f"[tool result: {tool_name} id={tool_call_id}]") + try: + result = json.loads(tool_message["content"]) + print(json.dumps(result, indent=2)) + except (TypeError, ValueError): + print(tool_message["content"]) messages.extend(tool_messages) print("\nFinal answer:\n") diff --git a/examples/gateway/invoke_tools.py b/examples/gateway/invoke_tools.py index 62f2d02a..3ffdfb69 100644 --- a/examples/gateway/invoke_tools.py +++ b/examples/gateway/invoke_tools.py @@ -5,16 +5,16 @@ Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run - END_USER_ID + ACTOR_ID """ import os -from pydo.action_gateway import Client +from pydo.action_gateway import ActionGatewayClient -client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) -session = client.sessions.create( - end_user_id=os.environ.get("END_USER_ID", "example-user"), +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), ) envelope = session.tools.invoke( diff --git a/examples/gateway/list_tools.py b/examples/gateway/list_tools.py index 002faf53..6c5611b3 100644 --- a/examples/gateway/list_tools.py +++ b/examples/gateway/list_tools.py @@ -8,16 +8,16 @@ Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run - END_USER_ID + ACTOR_ID """ import os -from pydo.action_gateway import Client +from pydo.action_gateway import ActionGatewayClient -client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) -session = client.sessions.create( - end_user_id=os.environ.get("END_USER_ID", "example-user"), +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), ) print("MCP URL:", session.url) diff --git a/examples/gateway/messages_tool_use.py b/examples/gateway/messages_tool_use.py index af2a1d8d..566c6249 100644 --- a/examples/gateway/messages_tool_use.py +++ b/examples/gateway/messages_tool_use.py @@ -5,14 +5,14 @@ Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run - END_USER_ID + ACTOR_ID MODEL PROMPT """ import os -from pydo.action_gateway import Client, MessagesProvider +from pydo.action_gateway import ActionGatewayClient, MessagesProvider def _assistant_content(response) -> list: @@ -41,12 +41,12 @@ def _print_final_message(response) -> None: print(response) -client = Client( +client = ActionGatewayClient( token=os.environ["DIGITALOCEAN_TOKEN"], gateway_provider=MessagesProvider(), ) -session = client.sessions.create( - end_user_id=os.environ.get("END_USER_ID", "example-user"), +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), ) model = os.environ.get("MODEL", "claude-opus-4-6") diff --git a/examples/gateway/responses_tool_use.py b/examples/gateway/responses_tool_use.py new file mode 100644 index 00000000..005b11e7 --- /dev/null +++ b/examples/gateway/responses_tool_use.py @@ -0,0 +1,34 @@ +"""Tool use via the Responses API + Action Gateway session. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID + MODEL + PROMPT +""" + +import os + +from pydo.action_gateway import ActionGatewayClient, ResponsesProvider + +client = ActionGatewayClient( + token=os.environ["DIGITALOCEAN_TOKEN"], + gateway_provider=ResponsesProvider(), +) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), +) + +response = client.responses.create( + model=os.environ.get("MODEL", "openai-gpt-4o"), + input=os.environ.get( + "PROMPT", + "What DigitalOcean Droplet sizes are available in NYC3?", + ), + tools=session.tools(), +) + +for tool_output in session.handle_tool_calls(response): + print(tool_output) diff --git a/examples/gateway/search_tools.py b/examples/gateway/search_tools.py index 3f86e107..715866c6 100644 --- a/examples/gateway/search_tools.py +++ b/examples/gateway/search_tools.py @@ -5,17 +5,17 @@ Optional env: PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run - END_USER_ID + ACTOR_ID USE_CASE """ import os -from pydo.action_gateway import Client +from pydo.action_gateway import ActionGatewayClient -client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) -session = client.sessions.create( - end_user_id=os.environ.get("END_USER_ID", "example-user"), +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), ) use_case = os.environ.get("USE_CASE", "search the public web for a topic") diff --git a/examples/gateway/toolbelt_policy.py b/examples/gateway/toolbelt_policy.py new file mode 100644 index 00000000..07bf16a9 --- /dev/null +++ b/examples/gateway/toolbelt_policy.py @@ -0,0 +1,28 @@ +"""Create a session whose policy allows one pinned toolbelt. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) + +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "toolbelt:search-toolbelt@1", "action": "allow"}, + {"tool": "digitalocean_size-list", "action": "allow"}, + ], + }, +) + +print("MCP URL:", session.url) +print("tools:", [tool["function"]["name"] for tool in session.tools()]) diff --git a/openapi/README.md b/openapi/README.md new file mode 100644 index 00000000..a1366e7e --- /dev/null +++ b/openapi/README.md @@ -0,0 +1,19 @@ +# Action Gateway OpenAPI patch + +`action-gateway-toolbelts.patch` adds the public Toolbelts API used to generate +the synchronous and asynchronous `client.toolbelts` operations. + +The patch is based on the OpenAPI revision recorded in +`DO_OPENAPI_COMMIT_SHA.txt`. To regenerate the SDK: + +```shell +git -C /path/to/openapi checkout "$(cat DO_OPENAPI_COMMIT_SHA.txt)" +git -C /path/to/openapi apply "$PWD/openapi/action-gateway-toolbelts.patch" +make -C /path/to/openapi bundle \ + BUNDLE_PATH="$PWD/DigitalOcean-public.v2.yaml" +SPEC_FILE="$PWD/DigitalOcean-public.v2.yaml" make generate +``` + +Submit the same source changes to the DigitalOcean OpenAPI repository. Once +they are published and `DO_OPENAPI_COMMIT_SHA.txt` advances to include them, +remove this transitional patch. diff --git a/openapi/action-gateway-toolbelts.patch b/openapi/action-gateway-toolbelts.patch new file mode 100644 index 00000000..f3f2647c --- /dev/null +++ b/openapi/action-gateway-toolbelts.patch @@ -0,0 +1,518 @@ +diff --git a/specification/DigitalOcean-public.v2.yaml b/specification/DigitalOcean-public.v2.yaml +index b8c29b4..6bbe488 100644 +--- a/specification/DigitalOcean-public.v2.yaml ++++ b/specification/DigitalOcean-public.v2.yaml +@@ -50,6 +50,9 @@ tags: + + - `Accept: application/vnd.digitalocean.reserveip+json` + ++ - name: Action Gateway ++ description: Manage versioned tool collections used by Action Gateway sessions. ++ + - name: Add-Ons + description: |- + Add-ons are third-party applications that can be added to your DigitalOcean account. +@@ -718,6 +721,26 @@ x-tagGroups: + - Serverless Inference + + paths: ++ /v2/action-gateway/toolbelts: ++ get: ++ $ref: "resources/action_gateway/toolbelts_list.yml" ++ post: ++ $ref: "resources/action_gateway/toolbelts_create.yml" ++ ++ /v2/action-gateway/toolbelts/{name}: ++ get: ++ $ref: "resources/action_gateway/toolbelts_get.yml" ++ delete: ++ $ref: "resources/action_gateway/toolbelts_delete.yml" ++ ++ /v2/action-gateway/toolbelts/{name}/tools/add: ++ post: ++ $ref: "resources/action_gateway/toolbelts_add_tools.yml" ++ ++ /v2/action-gateway/toolbelts/{name}/tools/remove: ++ post: ++ $ref: "resources/action_gateway/toolbelts_remove_tools.yml" ++ + /v2/1-clicks: + get: + $ref: "resources/1-clicks/oneClicks_list.yml" +diff --git a/specification/resources/action_gateway/models.yml b/specification/resources/action_gateway/models.yml +new file mode 100644 +index 0000000..c709cc0 +--- /dev/null ++++ b/specification/resources/action_gateway/models.yml +@@ -0,0 +1,193 @@ ++toolbelt: ++ type: object ++ required: ++ - name ++ - version ++ - tools ++ - status ++ - reference ++ - reference_latest ++ - tool_count ++ - created_at ++ - updated_at ++ properties: ++ name: ++ type: string ++ example: search-toolbelt ++ version: ++ type: string ++ pattern: '^[0-9]+$' ++ example: '1' ++ display_name: ++ type: string ++ maxLength: 128 ++ example: Search Tools ++ description: ++ type: string ++ maxLength: 255 ++ example: Tools for searching and fetching public web pages. ++ tools: ++ type: array ++ maxItems: 500 ++ items: ++ type: string ++ example: ++ - exa_web_search ++ - exa_web_fetch ++ status: ++ type: string ++ enum: ++ - active ++ - deprecated ++ example: active ++ reference: ++ type: string ++ description: A reference pinned to this immutable toolbelt version. ++ example: search-toolbelt@1 ++ reference_latest: ++ type: string ++ description: An unversioned reference to the latest active version. ++ example: search-toolbelt ++ tool_count: ++ type: integer ++ format: int32 ++ example: 2 ++ created_at: ++ type: string ++ format: date-time ++ example: '2026-06-11T12:00:00Z' ++ updated_at: ++ type: string ++ format: date-time ++ example: '2026-06-11T12:00:00Z' ++ ++toolbelt_summary: ++ type: object ++ required: ++ - name ++ - latest_version ++ - version_count ++ - tool_count ++ - status ++ - reference_latest ++ - updated_at ++ properties: ++ name: ++ type: string ++ example: search-toolbelt ++ display_name: ++ type: string ++ example: Search Tools ++ description: ++ type: string ++ example: Tools for searching and fetching public web pages. ++ latest_version: ++ type: string ++ example: '1' ++ version_count: ++ type: integer ++ format: int32 ++ example: 1 ++ tool_count: ++ type: integer ++ format: int32 ++ example: 2 ++ status: ++ type: string ++ enum: ++ - active ++ - deprecated ++ example: active ++ reference_latest: ++ type: string ++ example: search-toolbelt ++ updated_at: ++ type: string ++ format: date-time ++ example: '2026-06-11T12:00:00Z' ++ ++toolbelt_create: ++ type: object ++ required: ++ - name ++ - tools ++ properties: ++ name: ++ type: string ++ pattern: '^[a-z][a-z0-9_-]{0,63}$' ++ example: search-toolbelt ++ version: ++ type: string ++ pattern: '^[0-9]+$' ++ default: '1' ++ display_name: ++ type: string ++ maxLength: 128 ++ example: Search Tools ++ description: ++ type: string ++ maxLength: 255 ++ example: Tools for searching and fetching public web pages. ++ tools: ++ type: array ++ maxItems: 500 ++ items: ++ type: string ++ example: ++ - exa_web_search ++ - exa_web_fetch ++ ++toolbelt_tools: ++ type: object ++ required: ++ - tools ++ properties: ++ tools: ++ type: array ++ minItems: 1 ++ maxItems: 500 ++ items: ++ type: string ++ example: ++ - exa_web_search ++ ++toolbelt_response: ++ type: object ++ required: ++ - toolbelt ++ properties: ++ toolbelt: ++ $ref: '#/toolbelt' ++ ++toolbelts_response: ++ type: object ++ required: ++ - toolbelts ++ - pagination ++ properties: ++ toolbelts: ++ type: array ++ items: ++ $ref: '#/toolbelt_summary' ++ pagination: ++ $ref: '#/pagination' ++ ++pagination: ++ type: object ++ required: ++ - page ++ - per_page ++ - total ++ properties: ++ page: ++ type: integer ++ format: int32 ++ example: 1 ++ per_page: ++ type: integer ++ format: int32 ++ example: 20 ++ total: ++ type: integer ++ format: int32 ++ example: 1 +diff --git a/specification/resources/action_gateway/parameters.yml b/specification/resources/action_gateway/parameters.yml +new file mode 100644 +index 0000000..8f40fbf +--- /dev/null ++++ b/specification/resources/action_gateway/parameters.yml +@@ -0,0 +1,33 @@ ++toolbelt_name: ++ name: name ++ in: path ++ required: true ++ description: The natural key identifying the toolbelt. ++ schema: ++ type: string ++ pattern: '^[a-z][a-z0-9_-]{0,63}$' ++ example: search-toolbelt ++ ++toolbelt_version: ++ name: version ++ in: query ++ required: false ++ description: An immutable numeric toolbelt version. Omit to retrieve the latest active version. ++ schema: ++ type: string ++ pattern: '^[0-9]+$' ++ example: '1' ++ ++toolbelt_status: ++ name: status ++ in: query ++ required: false ++ description: Filter toolbelts by status. ++ schema: ++ type: string ++ enum: ++ - active ++ - deprecated ++ - all ++ default: active ++ example: active +diff --git a/specification/resources/action_gateway/response_headers.yml b/specification/resources/action_gateway/response_headers.yml +new file mode 100644 +index 0000000..c8ac026 +--- /dev/null ++++ b/specification/resources/action_gateway/response_headers.yml +@@ -0,0 +1,6 @@ ++ratelimit-limit: ++ $ref: '../../shared/headers.yml#/ratelimit-limit' ++ratelimit-remaining: ++ $ref: '../../shared/headers.yml#/ratelimit-remaining' ++ratelimit-reset: ++ $ref: '../../shared/headers.yml#/ratelimit-reset' +diff --git a/specification/resources/action_gateway/toolbelts_add_tools.yml b/specification/resources/action_gateway/toolbelts_add_tools.yml +new file mode 100644 +index 0000000..8253309 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_add_tools.yml +@@ -0,0 +1,36 @@ ++operationId: toolbelts_add_tools ++summary: Add Tools to a Toolbelt ++description: Adds provider-qualified tool names and creates a new immutable toolbelt version. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_name' ++requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_tools' ++responses: ++ '200': ++ description: The resulting toolbelt version. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_response' ++ '400': ++ $ref: '../../shared/responses/bad_request.yml' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '404': ++ $ref: '../../shared/responses/not_found.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_create.yml b/specification/resources/action_gateway/toolbelts_create.yml +new file mode 100644 +index 0000000..f496b03 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_create.yml +@@ -0,0 +1,34 @@ ++operationId: toolbelts_create ++summary: Create a Toolbelt ++description: Creates a versioned collection of provider-qualified Action Gateway tool names. ++tags: ++ - Action Gateway ++requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_create' ++responses: ++ '200': ++ description: A toolbelt was created successfully. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_response' ++ '400': ++ $ref: '../../shared/responses/bad_request.yml' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '409': ++ $ref: '../../shared/responses/conflict.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_delete.yml b/specification/resources/action_gateway/toolbelts_delete.yml +new file mode 100644 +index 0000000..ea6a984 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_delete.yml +@@ -0,0 +1,28 @@ ++operationId: toolbelts_delete ++summary: Delete a Toolbelt ++description: Deprecates the latest active version of a toolbelt. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_name' ++responses: ++ '200': ++ description: The toolbelt was deprecated successfully. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ type: object ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '404': ++ $ref: '../../shared/responses/not_found.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_get.yml b/specification/resources/action_gateway/toolbelts_get.yml +new file mode 100644 +index 0000000..b1e6992 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_get.yml +@@ -0,0 +1,29 @@ ++operationId: toolbelts_get ++summary: Retrieve a Toolbelt ++description: Retrieves the latest active version or a specified immutable version of a toolbelt. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_name' ++ - $ref: 'parameters.yml#/toolbelt_version' ++responses: ++ '200': ++ description: The toolbelt was retrieved successfully. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_response' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '404': ++ $ref: '../../shared/responses/not_found.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_list.yml b/specification/resources/action_gateway/toolbelts_list.yml +new file mode 100644 +index 0000000..f5c4776 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_list.yml +@@ -0,0 +1,28 @@ ++operationId: toolbelts_list ++summary: List Toolbelts ++description: Lists the latest version of each toolbelt owned by the authenticated team. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_status' ++ - $ref: '../../shared/parameters.yml#/page' ++ - $ref: '../../shared/parameters.yml#/per_page' ++responses: ++ '200': ++ description: Toolbelts were retrieved successfully. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelts_response' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_remove_tools.yml b/specification/resources/action_gateway/toolbelts_remove_tools.yml +new file mode 100644 +index 0000000..f28dc3f +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_remove_tools.yml +@@ -0,0 +1,36 @@ ++operationId: toolbelts_delete_tools ++summary: Remove Tools from a Toolbelt ++description: Removes tool names and creates a new immutable toolbelt version. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_name' ++requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_tools' ++responses: ++ '200': ++ description: The resulting toolbelt version. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_response' ++ '400': ++ $ref: '../../shared/responses/bad_request.yml' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '404': ++ $ref: '../../shared/responses/not_found.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] diff --git a/src/pydo/_client.py b/src/pydo/_client.py index b190e6ab..7a91eb73 100644 --- a/src/pydo/_client.py +++ b/src/pydo/_client.py @@ -59,6 +59,7 @@ SpacesKeyOperations, SshKeysOperations, TagsOperations, + ToolbeltsOperations, UptimeOperations, VectorDatabasesOperations, VolumeActionsOperations, @@ -77,6 +78,8 @@ class GeneratedClient: # pylint: disable=client-accepts-api-version-keyword,too-many-instance-attributes """GeneratedClient. + :ivar toolbelts: ToolbeltsOperations operations + :vartype toolbelts: pydo.operations.ToolbeltsOperations :ivar one_clicks: OneClicksOperations operations :vartype one_clicks: pydo.operations.OneClicksOperations :ivar account: AccountOperations operations @@ -211,11 +214,9 @@ def __init__( self._config.custom_hook_policy, self._config.logging_policy, policies.DistributedTracingPolicy(**kwargs), - ( - policies.SensitiveHeaderCleanupPolicy(**kwargs) - if self._config.redirect_policy - else None - ), + policies.SensitiveHeaderCleanupPolicy(**kwargs) + if self._config.redirect_policy + else None, self._config.http_logging_policy, ] self._client: PipelineClient = PipelineClient( @@ -225,6 +226,9 @@ def __init__( self._serialize = Serializer() self._deserialize = Deserializer() self._serialize.client_side_validation = False + self.toolbelts = ToolbeltsOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.one_clicks = OneClicksOperations( self._client, self._config, self._serialize, self._deserialize ) diff --git a/src/pydo/action_gateway/__init__.py b/src/pydo/action_gateway/__init__.py index dcbfe98c..5803e878 100644 --- a/src/pydo/action_gateway/__init__.py +++ b/src/pydo/action_gateway/__init__.py @@ -2,7 +2,7 @@ # Copyright (c) DigitalOcean. # Licensed under the Apache-2.0 License. # ------------------------------------ -"""Action Gateway entry point: ``from pydo.action_gateway import Client``. +"""Action Gateway entry point: ``from pydo.action_gateway import ActionGatewayClient``. Purpose-built client for the Action Gateway. Create a session first, then use ``session.tools`` / ``session.code`` / ``session.handle_tool_calls``. @@ -12,10 +12,10 @@ Example:: import os - from pydo.action_gateway import Client + from pydo.action_gateway import ActionGatewayClient - client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) - session = client.sessions.create(end_user_id="user-123") + client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) + session = client.session.create(actor_id="user-123") tools = session.tools() response = client.chat.completions.create( @@ -44,6 +44,7 @@ ResponsesProvider, Session, SessionsOperations, + Toolbelt, ToolCall, normalize_permissions, resolve_gateway_base_url, @@ -53,10 +54,13 @@ _GATEWAY_SURFACE: tuple = ( "base_url", "chat", + "create_toolbelt", "messages", "provider", "responses", + "session", "sessions", + "toolbelts", ) @@ -65,7 +69,7 @@ class Client(_DigitalOceanClient): Primary surface: - * ``client.sessions.create(end_user_id=...)`` → :class:`Session` + * ``client.session.create(actor_id=...)`` → :class:`Session` * ``session.tools`` / ``session.tools()`` — discover and wrap tools * ``session.code`` — sandboxed Python execution * ``session.handle_tool_calls(response)`` — run model tool calls @@ -101,8 +105,17 @@ def __init__( "ensure pydo.gateway is installed" ) self.sessions = gateway.sessions + self.session = self.sessions self.provider = gateway.provider + def create_toolbelt(self, name: str, tools, **kwargs) -> Toolbelt: + """Create a versioned collection of Action Gateway tools.""" + if isinstance(tools, (str, bytes)): + raise TypeError("tools must be an iterable of tool names") + body = {"name": name, "tools": list(tools), **kwargs} + response = self.toolbelts.create(body=body) + return Toolbelt(response["toolbelt"]) + @property def base_url(self) -> Optional[str]: """Resolved Action Gateway base URL.""" @@ -116,11 +129,16 @@ def __repr__(self) -> str: return "" +ActionGatewayClient = Client + + __all__ = [ "Client", + "ActionGatewayClient", "TokenCredentials", "Session", "SessionsOperations", + "Toolbelt", "ChatCompletionsProvider", "MessagesProvider", "ResponsesProvider", diff --git a/src/pydo/action_gateway/aio/__init__.py b/src/pydo/action_gateway/aio/__init__.py index 4d457827..86b3151a 100644 --- a/src/pydo/action_gateway/aio/__init__.py +++ b/src/pydo/action_gateway/aio/__init__.py @@ -3,7 +3,7 @@ # Licensed under the Apache-2.0 License. # ------------------------------------ # pylint: disable=duplicate-code -"""Async Action Gateway entry point: ``from pydo.action_gateway.aio import Client``. +"""Async Action Gateway entry point: ``ActionGatewayClient``. Asynchronous twin of :class:`pydo.action_gateway.Client`. Same surface, ``await``-friendly. See :mod:`pydo.action_gateway` for usage details. @@ -14,7 +14,10 @@ from pydo.aio import Client as _DigitalOceanClient from pydo.aio._patch import TokenCredentials -from pydo.aio.gateway import AsyncSession, AsyncSessionsOperations +from pydo.aio.gateway import ( + AsyncSession, + AsyncSessionsOperations, +) from pydo.gateway import ( META_CODE, META_INVOKE, @@ -26,6 +29,7 @@ GatewayToolError, MessagesProvider, ResponsesProvider, + Toolbelt, ToolCall, normalize_permissions, resolve_gateway_base_url, @@ -35,10 +39,13 @@ _GATEWAY_SURFACE: tuple = ( "base_url", "chat", + "create_toolbelt", "messages", "provider", "responses", + "session", "sessions", + "toolbelts", ) @@ -46,7 +53,7 @@ class Client(_DigitalOceanClient): """Action Gateway–focused DigitalOcean async client. Asynchronous counterpart to :class:`pydo.action_gateway.Client`. - Create a session with ``await client.sessions.create(end_user_id=...)``, + Create a session with ``await client.session.create(actor_id=...)``, then use ``session.tools`` / ``session.code`` / ``await session.handle_tool_calls(...)``. """ @@ -76,8 +83,17 @@ def __init__( "ensure pydo.aio.gateway is installed" ) self.sessions = gateway.sessions + self.session = self.sessions self.provider = gateway.provider + async def create_toolbelt(self, name: str, tools, **kwargs) -> Toolbelt: + """Create a versioned collection of Action Gateway tools.""" + if isinstance(tools, (str, bytes)): + raise TypeError("tools must be an iterable of tool names") + body = {"name": name, "tools": list(tools), **kwargs} + response = await self.toolbelts.create(body=body) + return Toolbelt(response["toolbelt"]) + @property def base_url(self) -> Optional[str]: """Resolved Action Gateway base URL.""" @@ -91,11 +107,16 @@ def __repr__(self) -> str: return "" +ActionGatewayClient = Client + + __all__ = [ "Client", + "ActionGatewayClient", "TokenCredentials", "AsyncSession", "AsyncSessionsOperations", + "Toolbelt", "ChatCompletionsProvider", "MessagesProvider", "ResponsesProvider", diff --git a/src/pydo/aio/_client.py b/src/pydo/aio/_client.py index 6a8b62f4..c8769da0 100644 --- a/src/pydo/aio/_client.py +++ b/src/pydo/aio/_client.py @@ -59,6 +59,7 @@ SpacesKeyOperations, SshKeysOperations, TagsOperations, + ToolbeltsOperations, UptimeOperations, VectorDatabasesOperations, VolumeActionsOperations, @@ -77,6 +78,8 @@ class GeneratedClient: # pylint: disable=client-accepts-api-version-keyword,too-many-instance-attributes """GeneratedClient. + :ivar toolbelts: ToolbeltsOperations operations + :vartype toolbelts: pydo.aio.operations.ToolbeltsOperations :ivar one_clicks: OneClicksOperations operations :vartype one_clicks: pydo.aio.operations.OneClicksOperations :ivar account: AccountOperations operations @@ -211,11 +214,9 @@ def __init__( self._config.custom_hook_policy, self._config.logging_policy, policies.DistributedTracingPolicy(**kwargs), - ( - policies.SensitiveHeaderCleanupPolicy(**kwargs) - if self._config.redirect_policy - else None - ), + policies.SensitiveHeaderCleanupPolicy(**kwargs) + if self._config.redirect_policy + else None, self._config.http_logging_policy, ] self._client: AsyncPipelineClient = AsyncPipelineClient( @@ -225,6 +226,9 @@ def __init__( self._serialize = Serializer() self._deserialize = Deserializer() self._serialize.client_side_validation = False + self.toolbelts = ToolbeltsOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.one_clicks = OneClicksOperations( self._client, self._config, self._serialize, self._deserialize ) diff --git a/src/pydo/aio/gateway/__init__.py b/src/pydo/aio/gateway/__init__.py index 9672f534..1bc0b53b 100644 --- a/src/pydo/aio/gateway/__init__.py +++ b/src/pydo/aio/gateway/__init__.py @@ -64,14 +64,12 @@ async def handle_tool_calls( if self.tools is None: raise RuntimeError( "create a session first: session = await client.sessions.create(" - "end_user_id=...); then await session.handle_tool_calls(response)" + "actor_id=...); then await session.handle_tool_calls(response)" ) calls = self.provider.extract_tool_calls(response) if not calls: return [] - results = await async_execute_tool_calls( - calls, self.tools, rationale=rationale - ) + results = await async_execute_tool_calls(calls, self.tools, rationale=rationale) return self.provider.format_tool_results(calls, results) async def execute_tool_calls( @@ -83,7 +81,7 @@ async def execute_tool_calls( if self.tools is None: raise RuntimeError( "create a session first via await client.sessions.create(" - "end_user_id=...)" + "actor_id=...)" ) return await async_execute_tool_calls(calls, self.tools, rationale=rationale) diff --git a/src/pydo/aio/gateway/custom_operations.py b/src/pydo/aio/gateway/custom_operations.py index b4b14923..2659f8a3 100644 --- a/src/pydo/aio/gateway/custom_operations.py +++ b/src/pydo/aio/gateway/custom_operations.py @@ -33,6 +33,7 @@ ) from pydo.gateway.providers import _error_payload, _get from pydo.gateway.transport import ( + ACTOR_ID_HEADER, SESSION_ID_HEADER, _MCP_HEADERS, _MCP_META_PATH, @@ -43,6 +44,7 @@ _REST_INVOKE_PATH, _REST_SEARCH_PATH, _REST_TOOLS_PATH, + _external_session_id, _parse_json_body, _parse_jsonrpc, _raise_gateway_http_error, @@ -66,18 +68,32 @@ async def call_tool( class AsyncMCPTransport(AsyncGatewayTransport): """Async JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" - def __init__(self, base_url_proxy: Any, *, session_id: Optional[str] = None): + def __init__( + self, + base_url_proxy: Any, + *, + session_id: Optional[str] = None, + actor_id: str, + endpoint_url: Optional[str] = None, + ): + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required for AsyncMCPTransport") self._client = base_url_proxy self._ids = itertools.count(1) - self.session_id = session_id + self.session_id = _external_session_id(session_id) if session_id else None + self.actor_id = str(actor_id).strip() + self.endpoint_url = endpoint_url def _headers(self) -> Dict[str, str]: headers = dict(_MCP_HEADERS) if self.session_id: headers[SESSION_ID_HEADER] = self.session_id + headers[ACTOR_ID_HEADER] = self.actor_id return headers async def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if self.endpoint_url: + path = self.endpoint_url request = HttpRequest( "POST", path, @@ -87,13 +103,9 @@ async def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: request.url = self._client.format_url(request.url) pipeline_response = await self._client._pipeline.run(request) response = pipeline_response.http_response + body = await response.read() if response.status_code != 200: - try: - await response.read() - except Exception: # noqa: BLE001 - pass _raise_gateway_http_error(response) - body = await response.read() return _parse_jsonrpc(body) async def _rpc( @@ -130,15 +142,19 @@ async def call_tool( class AsyncRESTTransport(AsyncGatewayTransport): """Async REST transport; requires ``session_id`` via ``X-Session-Id``.""" - def __init__(self, base_url_proxy: Any, *, session_id: str): + def __init__(self, base_url_proxy: Any, *, session_id: str, actor_id: str): if not session_id: raise ValueError("session_id is required for AsyncRESTTransport") + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required for AsyncRESTTransport") self._client = base_url_proxy - self.session_id = session_id + self.session_id = _external_session_id(session_id) + self.actor_id = str(actor_id).strip() def _headers(self) -> Dict[str, str]: headers = dict(_REST_HEADERS) headers[SESSION_ID_HEADER] = self.session_id + headers[ACTOR_ID_HEADER] = self.actor_id return headers async def _request( @@ -154,13 +170,9 @@ async def _request( request.url = self._client.format_url(request.url) pipeline_response = await self._client._pipeline.run(request) response = pipeline_response.http_response + body = await response.read() if response.status_code != 200: - try: - await response.read() - except Exception: # noqa: BLE001 - pass _raise_gateway_http_error(response) - body = await response.read() return _parse_json_body(body) async def list_tools(self, *, meta: bool) -> List[Any]: diff --git a/src/pydo/aio/gateway/session.py b/src/pydo/aio/gateway/session.py index 7494409d..6e7b448e 100644 --- a/src/pydo/aio/gateway/session.py +++ b/src/pydo/aio/gateway/session.py @@ -7,7 +7,6 @@ from __future__ import annotations -import json as _json import uuid from typing import Any, Dict, List, Optional, Sequence @@ -21,12 +20,11 @@ _parse_json_body, _raise_gateway_http_error, resolve_gateway_base_url, - session_mcp_url, ) from .custom_operations import ( AsyncCodeOperations, - AsyncRESTTransport, + AsyncMCPTransport, AsyncToolsOperations, async_execute_tool_calls, ) @@ -48,10 +46,10 @@ def __init__( self, *, session_urn: str, - end_user_id: str, + actor_id: str, name: str, policy: Dict[str, Any], - gateway_base_url: str, + mcp_url: str, tools: AsyncToolsOperations, code: AsyncCodeOperations, provider: BaseProvider, @@ -59,10 +57,10 @@ def __init__( ): self.session_urn = session_urn self.id = session_urn - self.end_user_id = end_user_id + self.actor_id = actor_id self.name = name self.policy = policy - self._gateway_base_url = gateway_base_url.rstrip("/") + self._mcp_url = mcp_url self.tools = tools self.code = code self.provider = provider @@ -70,7 +68,7 @@ def __init__( @property def url(self) -> str: - return session_mcp_url(self._gateway_base_url, self.session_urn) + return self._mcp_url async def handle_tool_calls( self, @@ -81,9 +79,7 @@ async def handle_tool_calls( calls = self.provider.extract_tool_calls(response) if not calls: return [] - results = await async_execute_tool_calls( - calls, self.tools, rationale=rationale - ) + results = await async_execute_tool_calls(calls, self.tools, rationale=rationale) return self.provider.format_tool_results(calls, results) async def execute_tool_calls( @@ -95,10 +91,7 @@ async def execute_tool_calls( return await async_execute_tool_calls(calls, self.tools, rationale=rationale) def __repr__(self) -> str: # pragma: no cover - return ( - f"" - ) + return f"" class AsyncSessionsOperations: @@ -117,20 +110,20 @@ def __init__( async def create( self, - end_user_id: str, + actor_id: str, *, name: Optional[str] = None, permissions: Optional[Dict[str, Any]] = None, ) -> AsyncSession: - if not end_user_id or not str(end_user_id).strip(): - raise ValueError("end_user_id is required") + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required") session_name = name or f"pydo-session-{uuid.uuid4().hex[:8]}" policy = normalize_permissions(permissions) body = { "name": session_name, - "policy_json": _json.dumps(policy, separators=(",", ":")), - "end_user_id": str(end_user_id).strip(), + "policy": policy, + "actor_id": str(actor_id).strip(), } raw_session = await self._post_create(body) @@ -140,19 +133,26 @@ async def create( f"session create response missing sessionUrn: {raw_session!r}" ) - transport = AsyncRESTTransport( + mcp_url = _pick(raw_session, "mcpUrl", "mcp_url") + if not mcp_url: + raise GatewayProtocolError( + f"session create response missing mcpUrl: {raw_session!r}" + ) + + transport = AsyncMCPTransport( _BaseURLProxy(self._parent._client, self._gateway_base_url), session_id=session_urn, + actor_id=actor_id, + endpoint_url=mcp_url, ) tools = AsyncToolsOperations(transport, self._provider) code = AsyncCodeOperations(transport) return AsyncSession( session_urn=session_urn, - end_user_id=_pick(raw_session, "endUserId", "end_user_id") - or str(end_user_id).strip(), + actor_id=str(actor_id).strip(), name=_pick(raw_session, "name") or session_name, policy=policy, - gateway_base_url=self._gateway_base_url, + mcp_url=mcp_url, tools=tools, code=code, provider=self._provider, @@ -173,13 +173,9 @@ async def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: request.url = client.format_url(request.url) pipeline_response = await client._pipeline.run(request) response = pipeline_response.http_response + body_bytes = await response.read() if response.status_code not in (200, 201): - try: - await response.read() - except Exception: # noqa: BLE001 - pass _raise_gateway_http_error(response) - body_bytes = await response.read() payload = _parse_json_body(body_bytes) if not isinstance(payload, dict): raise GatewayProtocolError( @@ -190,7 +186,11 @@ async def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: raise GatewayProtocolError( f"session create response missing session object: {payload!r}" ) - return dict(session) + result = dict(session) + mcp_url = _pick(payload, "mcpUrl", "mcp_url") + if mcp_url: + result["mcpUrl"] = mcp_url + return result __all__ = ["AsyncSession", "AsyncSessionsOperations"] diff --git a/src/pydo/aio/operations/__init__.py b/src/pydo/aio/operations/__init__.py index 2de68fec..574a164f 100644 --- a/src/pydo/aio/operations/__init__.py +++ b/src/pydo/aio/operations/__init__.py @@ -4,6 +4,7 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- +from ._operations import ToolbeltsOperations from ._operations import OneClicksOperations from ._operations import AccountOperations from ._operations import SshKeysOperations @@ -63,6 +64,7 @@ from ._patch import patch_sdk as _patch_sdk __all__ = [ + "ToolbeltsOperations", "OneClicksOperations", "AccountOperations", "SshKeysOperations", diff --git a/src/pydo/aio/operations/_operations.py b/src/pydo/aio/operations/_operations.py index 120e722d..278dba52 100644 --- a/src/pydo/aio/operations/_operations.py +++ b/src/pydo/aio/operations/_operations.py @@ -638,6 +638,12 @@ build_tags_get_request, build_tags_list_request, build_tags_unassign_resources_request, + build_toolbelts_add_tools_request, + build_toolbelts_create_request, + build_toolbelts_delete_request, + build_toolbelts_delete_tools_request, + build_toolbelts_get_request, + build_toolbelts_list_request, build_uptime_create_alert_request, build_uptime_create_check_request, build_uptime_delete_alert_request, @@ -706,6 +712,1264 @@ ] +class ToolbeltsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`toolbelts` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list( + self, + *, + status: str = "active", + page: int = 1, + per_page: int = 20, + **kwargs: Any + ) -> JSON: + """List Toolbelts. + + Lists the latest version of each toolbelt owned by the authenticated team. + + :keyword status: Filter toolbelts by status. Known values are: "active", "deprecated", and + "all". Default value is "active". + :paramtype status: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "toolbelts": [ + { + "latest_version": "str", # Required. + "name": "str", # Required. + "reference_latest": "str", # Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "updated_at": "2020-02-20 00:00:00", # Required. + "version_count": 0, # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_list_request( + status=status, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "name": "str", # Required. + "tools": [ + "str" # Required. + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "version": "1" # Optional. Default value is "1". + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "name": "str", # Required. + "tools": [ + "str" # Required. + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "version": "1" # Optional. Default value is "1". + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 409]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 409: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def get( + self, name: str, *, version: Optional[str] = None, **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Toolbelt. + + Retrieves the latest active version or a specified immutable version of a toolbelt. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :keyword version: An immutable numeric toolbelt version. Omit to retrieve the latest active + version. Default value is None. + :paramtype version: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_get_request( + name=name, + version=version, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def delete(self, name: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete a Toolbelt. + + Deprecates the latest active version of a toolbelt. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :return: JSON or JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_delete_request( + name=name, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def add_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def add_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def add_tools( + self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_add_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def delete_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def delete_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def delete_tools( + self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_delete_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + class OneClicksOperations: """ .. warning:: diff --git a/src/pydo/gateway/__init__.py b/src/pydo/gateway/__init__.py index 6325552b..b4f819d3 100644 --- a/src/pydo/gateway/__init__.py +++ b/src/pydo/gateway/__init__.py @@ -24,10 +24,15 @@ GatewayToolError, RecoveryHint, ToolCall, + Toolbelt, ToolErrorClass, ToolResultStatus, ) -from .custom_operations import CodeOperations, ToolsOperations, normalize_invoke_arguments +from .custom_operations import ( + CodeOperations, + ToolsOperations, + normalize_invoke_arguments, +) from .providers import ( BaseProvider, ChatCompletionsProvider, @@ -45,6 +50,7 @@ serialize_policy_json, ) from .transport import ( + ACTOR_ID_HEADER, MCP_PROTOCOL_VERSION, SESSION_ID_HEADER, DEFAULT_GATEWAY_BASE_URL, @@ -106,7 +112,7 @@ def handle_tool_calls( if self.tools is None: raise RuntimeError( "create a session first: session = client.sessions.create(" - "end_user_id=...); then session.handle_tool_calls(response)" + "actor_id=...); then session.handle_tool_calls(response)" ) calls = self.provider.extract_tool_calls(response) if not calls: @@ -122,7 +128,7 @@ def execute_tool_calls( ) -> List[Any]: if self.tools is None: raise RuntimeError( - "create a session first via client.sessions.create(end_user_id=...)" + "create a session first via client.sessions.create(actor_id=...)" ) return execute_tool_calls(calls, self.tools, rationale=rationale) @@ -140,6 +146,7 @@ def execute_tool_calls( "RESTTransport", "MCPTransport", "MCP_PROTOCOL_VERSION", + "ACTOR_ID_HEADER", "SESSION_ID_HEADER", "session_mcp_url", "BaseProvider", @@ -151,6 +158,7 @@ def execute_tool_calls( "simplify_inference_tool_schema", "simplify_messages_input_schema", "ToolCall", + "Toolbelt", "GatewayToolError", "GatewayProtocolError", "ToolErrorClass", diff --git a/src/pydo/gateway/custom_models.py b/src/pydo/gateway/custom_models.py index 0fb568fa..1a5785db 100644 --- a/src/pydo/gateway/custom_models.py +++ b/src/pydo/gateway/custom_models.py @@ -129,6 +129,18 @@ def __repr__(self) -> str: # pragma: no cover - debug aid ) +class Toolbelt(dict): + """A generated toolbelt response with a concise ``ref`` alias.""" + + def __getattr__(self, name: str) -> Any: + if name == "ref": + return self.get("reference") + try: + return self[name] + except KeyError: + raise AttributeError(name) from None + + __all__: List[str] = [ "META_SEARCH", "META_INVOKE", @@ -140,4 +152,5 @@ def __repr__(self) -> str: # pragma: no cover - debug aid "GatewayToolError", "GatewayProtocolError", "ToolCall", + "Toolbelt", ] diff --git a/src/pydo/gateway/custom_operations.py b/src/pydo/gateway/custom_operations.py index 73278dda..04eb27e0 100644 --- a/src/pydo/gateway/custom_operations.py +++ b/src/pydo/gateway/custom_operations.py @@ -105,7 +105,9 @@ def _normalize_invoke_entry(spec: Any) -> Dict[str, Any]: arguments = spec.get("arguments") if arguments is None: - hoisted = {k: v for k, v in spec.items() if k not in _INVOKE_ENTRY_RESERVED_KEYS} + hoisted = { + k: v for k, v in spec.items() if k not in _INVOKE_ENTRY_RESERVED_KEYS + } arguments = hoisted if hoisted else {} else: arguments = _decode_json_object(arguments) diff --git a/src/pydo/gateway/providers.py b/src/pydo/gateway/providers.py index b18dc1a9..64195467 100644 --- a/src/pydo/gateway/providers.py +++ b/src/pydo/gateway/providers.py @@ -288,7 +288,12 @@ def execute_tool_calls( results[index] = tools_operations._transport.call_tool( call.name, arguments, meta=True ) - except (GatewayToolError, TypeError, ValueError, _json.JSONDecodeError) as exc: + except ( + GatewayToolError, + TypeError, + ValueError, + _json.JSONDecodeError, + ) as exc: results[index] = _error_payload(exc) else: concrete.append(index) diff --git a/src/pydo/gateway/session.py b/src/pydo/gateway/session.py index 7ab9f22e..7aec90ff 100644 --- a/src/pydo/gateway/session.py +++ b/src/pydo/gateway/session.py @@ -19,15 +19,14 @@ from .custom_operations import CodeOperations, ToolsOperations from .providers import BaseProvider, default_provider, execute_tool_calls from .transport import ( - RESTTransport, + MCPTransport, _parse_json_body, _raise_gateway_http_error, resolve_gateway_base_url, - session_mcp_url, ) _SESSIONS_PATH = "/v2/action-gateway/sessions" -_DEFAULT_POLICY: Dict[str, Any] = {"defaultAction": "allow", "rules": []} +_DEFAULT_POLICY: Dict[str, Any] = {"defaultAction": "allow"} def _pick(data: Dict[str, Any], *keys: str) -> Any: @@ -41,7 +40,7 @@ def normalize_permissions(permissions: Optional[Dict[str, Any]]) -> Dict[str, An """Normalize SDK permissions into the wire policy object. Accepts snake_case ``default_action`` or wire ``defaultAction``. When - omitted, returns ``{"defaultAction": "allow", "rules": []}``. + omitted, returns ``{"defaultAction": "allow"}``. """ if permissions is None: return dict(_DEFAULT_POLICY) @@ -56,15 +55,18 @@ def normalize_permissions(permissions: Optional[Dict[str, Any]]) -> Dict[str, An for rule in rules_in: if not isinstance(rule, dict): raise TypeError("each permissions rule must be a dict") + if "toolbelt" in rule: + raise ValueError( + "toolbelt permissions are no longer supported; " + "use a tool value such as 'toolbelt:my-belt@1'" + ) entry: Dict[str, Any] = {"action": rule.get("action") or "allow"} if rule.get("tool"): entry["tool"] = rule["tool"] - if rule.get("toolbelt"): - entry["toolbelt"] = rule["toolbelt"] if rule.get("match"): entry["match"] = rule["match"] - if "tool" not in entry and "toolbelt" not in entry: - raise ValueError("each permissions rule requires tool or toolbelt") + if "tool" not in entry: + raise ValueError("each permissions rule requires tool") rules.append(entry) return {"defaultAction": default_action, "rules": rules} @@ -74,7 +76,7 @@ def serialize_policy_json(permissions: Optional[Dict[str, Any]]) -> str: class Session: - """A gateway session bound to an ``end_user_id`` and tool policy. + """A gateway session bound to an ``actor_id`` and tool policy. Create via :meth:`SessionsOperations.create`. Use ``url`` for external MCP clients, ``tools()`` for inference ``tools=``, and @@ -85,10 +87,10 @@ def __init__( self, *, session_urn: str, - end_user_id: str, + actor_id: str, name: str, policy: Dict[str, Any], - gateway_base_url: str, + mcp_url: str, tools: ToolsOperations, code: CodeOperations, provider: BaseProvider, @@ -96,10 +98,10 @@ def __init__( ): self.session_urn = session_urn self.id = session_urn - self.end_user_id = end_user_id + self.actor_id = actor_id self.name = name self.policy = policy - self._gateway_base_url = gateway_base_url.rstrip("/") + self._mcp_url = mcp_url self.tools = tools self.code = code self.provider = provider @@ -108,7 +110,7 @@ def __init__( @property def url(self) -> str: """Session-pinned MCP URL for external MCP clients.""" - return session_mcp_url(self._gateway_base_url, self.session_urn) + return self._mcp_url def handle_tool_calls( self, @@ -133,7 +135,7 @@ def execute_tool_calls( return execute_tool_calls(calls, self.tools, rationale=rationale) def __repr__(self) -> str: # pragma: no cover - debug aid - return f"" + return f"" class SessionsOperations: @@ -152,27 +154,27 @@ def __init__( def create( self, - end_user_id: str, + actor_id: str, *, name: Optional[str] = None, permissions: Optional[Dict[str, Any]] = None, ) -> Session: """Create a session. - :param end_user_id: Required end-user identifier bound to the session. + :param actor_id: Required actor identifier used to evaluate the policy. :param name: Optional display name (auto-generated when omitted). :param permissions: Optional policy. When omitted, defaults to - ``{"defaultAction": "allow", "rules": []}``. + ``{"defaultAction": "allow"}``. """ - if not end_user_id or not str(end_user_id).strip(): - raise ValueError("end_user_id is required") + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required") session_name = name or f"pydo-session-{uuid.uuid4().hex[:8]}" policy = normalize_permissions(permissions) body = { "name": session_name, - "policy_json": _json.dumps(policy, separators=(",", ":")), - "end_user_id": str(end_user_id).strip(), + "policy": policy, + "actor_id": str(actor_id).strip(), } raw_session = self._post_create(body) @@ -182,19 +184,26 @@ def create( f"session create response missing sessionUrn: {raw_session!r}" ) - transport = RESTTransport( + mcp_url = _pick(raw_session, "mcpUrl", "mcp_url") + if not mcp_url: + raise GatewayProtocolError( + f"session create response missing mcpUrl: {raw_session!r}" + ) + + transport = MCPTransport( _BaseURLProxy(self._parent._client, self._gateway_base_url), session_id=session_urn, + actor_id=actor_id, + endpoint_url=mcp_url, ) tools = ToolsOperations(transport, self._provider) code = CodeOperations(transport) return Session( session_urn=session_urn, - end_user_id=_pick(raw_session, "endUserId", "end_user_id") - or str(end_user_id).strip(), + actor_id=str(actor_id).strip(), name=_pick(raw_session, "name") or session_name, policy=policy, - gateway_base_url=self._gateway_base_url, + mcp_url=mcp_url, tools=tools, code=code, provider=self._provider, @@ -215,11 +224,10 @@ def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: request.url = client.format_url(request.url) pipeline_response = client._pipeline.run(request) response = pipeline_response.http_response + response_body = response.text() if hasattr(response, "text") else response.body() if response.status_code not in (200, 201): _raise_gateway_http_error(response) - payload = _parse_json_body( - response.text() if hasattr(response, "text") else response.body() - ) + payload = _parse_json_body(response_body) if not isinstance(payload, dict): raise GatewayProtocolError( f"unexpected session create response: {payload!r}" @@ -229,7 +237,11 @@ def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: raise GatewayProtocolError( f"session create response missing session object: {payload!r}" ) - return dict(session) + result = dict(session) + mcp_url = _pick(payload, "mcpUrl", "mcp_url") + if mcp_url: + result["mcpUrl"] = mcp_url + return result __all__ = [ diff --git a/src/pydo/gateway/transport.py b/src/pydo/gateway/transport.py index d09d0f27..453d70b7 100644 --- a/src/pydo/gateway/transport.py +++ b/src/pydo/gateway/transport.py @@ -7,8 +7,9 @@ The public SDK surface (``ToolsOperations`` / ``CodeOperations``) only talks to the small :class:`GatewayTransport` interface. The default transport is REST (``/tools/search``, ``/tools/invoke``, ``/code/execute``) and requires -a session id via ``X-Session-Id``. An :class:`MCPTransport` remains available -for callers that need JSON-RPC over ``/mcp`` / ``/mcp/meta``. +a session id via ``X-Session-Id`` and actor id via ``X-Actor-Id``. An +:class:`MCPTransport` remains available for callers that need JSON-RPC over +``/mcp`` / ``/mcp/meta``. """ from __future__ import annotations @@ -59,6 +60,7 @@ def resolve_gateway_base_url(explicit: Optional[str] = None) -> str: MCP_PROTOCOL_VERSION = "2025-06-18" SESSION_ID_HEADER = "X-Session-Id" +ACTOR_ID_HEADER = "X-Actor-Id" _MCP_PATH = "/mcp" _MCP_META_PATH = "/mcp/meta" @@ -330,10 +332,15 @@ def _unwrap_tool_result(payload: Any) -> Any: def session_mcp_url(gateway_base_url: str, session_urn: str) -> str: """Build the session-pinned MCP URL for external MCP clients.""" base = gateway_base_url.rstrip("/") - session_id = session_urn.rsplit(":", 1)[-1] + session_id = _external_session_id(session_urn) return f"{base}/mcp/session/{session_id}" +def _external_session_id(session_urn: str) -> str: + """Return the bare session ID accepted by Action Gateway ingress.""" + return session_urn.rsplit(":", 1)[-1] + + class GatewayTransport: """Swappable wire layer; MCP semantics are the lowest common denominator.""" @@ -347,18 +354,22 @@ def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: class RESTTransport(GatewayTransport): """REST over ``/tools``, ``/tools/search``, ``/tools/invoke``, ``/code/execute``. - Requires ``session_id`` (session URN) on every request via ``X-Session-Id``. + Requires a session URN or ID and actor ID on every request. """ - def __init__(self, base_url_proxy: Any, *, session_id: str): + def __init__(self, base_url_proxy: Any, *, session_id: str, actor_id: str): if not session_id: raise ValueError("session_id is required for RESTTransport") + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required for RESTTransport") self._client = base_url_proxy - self.session_id = session_id + self.session_id = _external_session_id(session_id) + self.actor_id = str(actor_id).strip() def _headers(self) -> Dict[str, str]: headers = dict(_REST_HEADERS) headers[SESSION_ID_HEADER] = self.session_id + headers[ACTOR_ID_HEADER] = self.actor_id return headers def _request( @@ -374,9 +385,9 @@ def _request( request.url = self._client.format_url(request.url) pipeline_response = self._client._pipeline.run(request) response = pipeline_response.http_response + body = response.text() if hasattr(response, "text") else response.body() if response.status_code != 200: _raise_gateway_http_error(response) - body = response.text() if hasattr(response, "text") else response.body() return _parse_json_body(body) def list_tools(self, *, meta: bool) -> List[Any]: @@ -417,18 +428,32 @@ def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: class MCPTransport(GatewayTransport): """JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" - def __init__(self, base_url_proxy: Any, *, session_id: Optional[str] = None): + def __init__( + self, + base_url_proxy: Any, + *, + session_id: Optional[str] = None, + actor_id: str, + endpoint_url: Optional[str] = None, + ): + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required for MCPTransport") self._client = base_url_proxy self._ids = itertools.count(1) - self.session_id = session_id + self.session_id = _external_session_id(session_id) if session_id else None + self.actor_id = str(actor_id).strip() + self.endpoint_url = endpoint_url def _headers(self) -> Dict[str, str]: headers = dict(_MCP_HEADERS) if self.session_id: headers[SESSION_ID_HEADER] = self.session_id + headers[ACTOR_ID_HEADER] = self.actor_id return headers def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if self.endpoint_url: + path = self.endpoint_url request = HttpRequest( "POST", path, @@ -438,9 +463,9 @@ def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: request.url = self._client.format_url(request.url) pipeline_response = self._client._pipeline.run(request) response = pipeline_response.http_response + body = response.text() if hasattr(response, "text") else response.body() if response.status_code != 200: _raise_gateway_http_error(response) - body = response.text() if hasattr(response, "text") else response.body() return _parse_jsonrpc(body) def _rpc( diff --git a/src/pydo/operations/__init__.py b/src/pydo/operations/__init__.py index 2de68fec..574a164f 100644 --- a/src/pydo/operations/__init__.py +++ b/src/pydo/operations/__init__.py @@ -4,6 +4,7 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- +from ._operations import ToolbeltsOperations from ._operations import OneClicksOperations from ._operations import AccountOperations from ._operations import SshKeysOperations @@ -63,6 +64,7 @@ from ._patch import patch_sdk as _patch_sdk __all__ = [ + "ToolbeltsOperations", "OneClicksOperations", "AccountOperations", "SshKeysOperations", diff --git a/src/pydo/operations/_operations.py b/src/pydo/operations/_operations.py index d587a331..2a169aef 100644 --- a/src/pydo/operations/_operations.py +++ b/src/pydo/operations/_operations.py @@ -51,6 +51,165 @@ _SERIALIZER.client_side_validation = False +def build_toolbelts_list_request( + *, status: str = "active", page: int = 1, per_page: int = 20, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts" + + # Construct parameters + if status is not None: + _params["status"] = _SERIALIZER.query("status", status, "str") + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_toolbelts_create_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts" + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + +def build_toolbelts_get_request( + name: str, *, version: Optional[str] = None, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts/{name}" + path_format_arguments = { + "name": _SERIALIZER.url( + "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" + ), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct parameters + if version is not None: + _params["version"] = _SERIALIZER.query( + "version", version, "str", pattern=r"^[0-9]+$" + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_toolbelts_delete_request(name: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts/{name}" + path_format_arguments = { + "name": _SERIALIZER.url( + "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" + ), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) + + +def build_toolbelts_add_tools_request(name: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts/{name}/tools/add" + path_format_arguments = { + "name": _SERIALIZER.url( + "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" + ), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + +def build_toolbelts_delete_tools_request(name: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts/{name}/tools/remove" + path_format_arguments = { + "name": _SERIALIZER.url( + "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" + ), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + def build_one_clicks_list_request( *, type: Optional[str] = None, **kwargs: Any ) -> HttpRequest: @@ -16469,6 +16628,1260 @@ def build_agent_inference_create_chat_completion_request( # pylint: disable=nam ) +class ToolbeltsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`toolbelts` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list( + self, + *, + status: str = "active", + page: int = 1, + per_page: int = 20, + **kwargs: Any, + ) -> JSON: + """List Toolbelts. + + Lists the latest version of each toolbelt owned by the authenticated team. + + :keyword status: Filter toolbelts by status. Known values are: "active", "deprecated", and + "all". Default value is "active". + :paramtype status: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "toolbelts": [ + { + "latest_version": "str", # Required. + "name": "str", # Required. + "reference_latest": "str", # Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "updated_at": "2020-02-20 00:00:00", # Required. + "version_count": 0, # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_list_request( + status=status, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "name": "str", # Required. + "tools": [ + "str" # Required. + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "version": "1" # Optional. Default value is "1". + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "name": "str", # Required. + "tools": [ + "str" # Required. + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "version": "1" # Optional. Default value is "1". + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 409]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 409: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def get(self, name: str, *, version: Optional[str] = None, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Toolbelt. + + Retrieves the latest active version or a specified immutable version of a toolbelt. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :keyword version: An immutable numeric toolbelt version. Omit to retrieve the latest active + version. Default value is None. + :paramtype version: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_get_request( + name=name, + version=version, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def delete(self, name: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete a Toolbelt. + + Deprecates the latest active version of a toolbelt. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :return: JSON or JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_delete_request( + name=name, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def add_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def add_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def add_tools(self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_add_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def delete_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def delete_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def delete_tools( + self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_delete_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + class OneClicksOperations: """ .. warning:: diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index a38f4df6..ec8305be 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -38,6 +38,13 @@ def __init__(self, status_code: int, body: Any = None): def text(self) -> str: return self._body_bytes.decode("utf-8") + @property + def content(self) -> bytes: + return self._body_bytes + + def json(self) -> Any: + return json.loads(self._body_bytes) + def body(self) -> bytes: return self._body_bytes @@ -174,7 +181,6 @@ def chat_tool_response( def session_create_response( *, session_urn: str = TEST_SESSION_URN, - end_user_id: str = "user-123", name: str = "test-session", ) -> dict: return { @@ -183,8 +189,9 @@ def session_create_response( "teamId": "42", "name": name, "policyJson": '{"defaultAction":"allow","rules":[]}', - "endUserId": end_user_id, - } + }, + "mcpUrl": f"{TEST_GATEWAY_URL}/mcp/session/test-session", + "tools": [], } @@ -213,10 +220,11 @@ def make_gateway( provider=None, *, session_id: str = TEST_SESSION_URN, + actor_id: str = "actor-123", ) -> GatewayResources: parent = make_parent(responses) proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) - transport = RESTTransport(proxy, session_id=session_id) + transport = RESTTransport(proxy, session_id=session_id, actor_id=actor_id) return GatewayResources( parent, gateway_endpoint=TEST_GATEWAY_URL, @@ -230,10 +238,11 @@ def make_async_gateway( provider=None, *, session_id: str = TEST_SESSION_URN, + actor_id: str = "actor-123", ) -> AsyncGatewayResources: parent = make_async_parent(responses) proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) - transport = AsyncRESTTransport(proxy, session_id=session_id) + transport = AsyncRESTTransport(proxy, session_id=session_id, actor_id=actor_id) return AsyncGatewayResources( parent, gateway_endpoint=TEST_GATEWAY_URL, diff --git a/tests/gateway/test_action_gateway_client.py b/tests/gateway/test_action_gateway_client.py index af91318f..efe2034f 100644 --- a/tests/gateway/test_action_gateway_client.py +++ b/tests/gateway/test_action_gateway_client.py @@ -3,7 +3,7 @@ # Copyright (c) DigitalOcean. # Licensed under the Apache-2.0 License. # ------------------------------------ -"""Smoke tests for ``from pydo.action_gateway import Client``.""" +"""Smoke tests for ``pydo.action_gateway.ActionGatewayClient``.""" from __future__ import annotations @@ -14,13 +14,15 @@ import pydo import pydo.action_gateway import pydo.aio -from pydo.action_gateway import Client as ActionGatewayClient +from pydo.action_gateway import ActionGatewayClient +from pydo.gateway.transport import _META_TOOL_DEFINITIONS from pydo.gateway import ChatCompletionsProvider, MessagesProvider from .conftest import ( FakeResponse, + call_result, chat_tool_response, - invoke_envelope, + jsonrpc_result, session_create_response, ) @@ -34,6 +36,7 @@ def test_namespace_module_exports(): assert hasattr(pydo.action_gateway, "Client") + assert hasattr(pydo.action_gateway, "ActionGatewayClient") assert hasattr(pydo.action_gateway, "Session") assert hasattr(pydo.action_gateway, "TokenCredentials") assert "Client" in pydo.action_gateway.__all__ @@ -51,8 +54,11 @@ def test_namespace_client_dir_is_gateway_focused(): "provider", "base_url", "chat", + "create_toolbelt", "messages", "responses", + "session", + "toolbelts", } assert expected <= surface for attr in ("tools", "code", "handle_tool_calls", "droplets"): @@ -67,6 +73,8 @@ def test_namespace_client_repr_is_distinct(): def test_sessions_delegate_to_gateway(): client = ActionGatewayClient(token="dummy") assert client.sessions is client.gateway.sessions + assert client.session is client.sessions + assert client.toolbelts is not None assert client.provider is client.gateway.provider @@ -79,10 +87,55 @@ def test_gateway_provider_kwarg(): assert not isinstance(client.provider, ChatCompletionsProvider) +def test_create_toolbelt_convenience_method(monkeypatch): + client = ActionGatewayClient(token="dummy") + response = FakeResponse( + 200, + { + "toolbelt": { + "name": "search-toolbelt", + "version": "1", + "reference": "search-toolbelt@1", + "tools": ["exa_web_search"], + } + }, + ) + + class Pipeline: + def __init__(self): + self.calls = [] + + def run(self, request, **_kwargs): + self.calls.append(request) + return type("R", (), {"http_response": response})() + + monkeypatch.setattr(client._client, "_pipeline", Pipeline()) + + toolbelt = client.create_toolbelt( + name="search-toolbelt", + tools=["exa_web_search"], + ) + + assert toolbelt.ref == "search-toolbelt@1" + request = client._client._pipeline.calls[0] + assert request.url.endswith("/v2/action-gateway/toolbelts") + assert json.loads(request.content) == { + "name": "search-toolbelt", + "tools": ["exa_web_search"], + } + + +def test_create_toolbelt_rejects_string_tools(): + client = ActionGatewayClient(token="dummy") + with pytest.raises(TypeError, match="iterable of tool names"): + client.create_toolbelt(name="search-toolbelt", tools="exa_web_search") + + def test_session_create_and_handle_tool_calls(monkeypatch): responses = [ FakeResponse(200, session_create_response()), - FakeResponse(200, invoke_envelope(output={"ok": True})), + FakeResponse(200, jsonrpc_result({"tools": _META_TOOL_DEFINITIONS})), + FakeResponse(200, jsonrpc_result(call_result(structured={"ok": True}))), ] client = ActionGatewayClient(token="dummy") @@ -96,7 +149,7 @@ def run(self, request, **_kwargs): monkeypatch.setattr(client._client, "_pipeline", Pipeline()) - session = client.sessions.create(end_user_id="user-123") + session = client.session.create(actor_id="user-123") tools = session.tools() assert tools[0]["function"]["name"] == "action_search" assert "mcp/session/" in session.url @@ -108,16 +161,19 @@ def run(self, request, **_kwargs): if isinstance(client._client._pipeline.calls[0].content, str) else client._client._pipeline.calls[0].content.decode("utf-8") ) - assert create_body["end_user_id"] == "user-123" + assert create_body["actor_id"] == "user-123" + assert "end_user_id" not in create_body @pytest.mark.skipif(not _HAS_AIO, reason="aiohttp extra not installed") def test_async_namespace_mirrors_sync(): import pydo.action_gateway.aio as action_gateway_aio - from pydo.action_gateway.aio import Client as AsyncActionGatewayClient + from pydo.action_gateway.aio import ActionGatewayClient as AsyncActionGatewayClient assert hasattr(action_gateway_aio, "Client") assert issubclass(action_gateway_aio.Client, pydo.aio.Client) client = AsyncActionGatewayClient(token="dummy") assert repr(client) == "" assert client.sessions is client.gateway.sessions + assert client.session is client.sessions + assert client.toolbelts is not None diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index 09e94456..57d544c7 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -13,18 +13,29 @@ import pytest from azure.core.exceptions import HttpResponseError -from pydo.aio.gateway import AsyncGatewayResources, AsyncMCPTransport +from pydo.aio.gateway import ( + AsyncGatewayResources, + AsyncMCPTransport, + AsyncSessionsOperations, +) from pydo.custom_extensions import _BaseURLProxy -from pydo.gateway import ChatCompletionsProvider, GatewayToolError, SESSION_ID_HEADER +from pydo.gateway import ( + ACTOR_ID_HEADER, + SESSION_ID_HEADER, + ChatCompletionsProvider, + GatewayToolError, +) from .conftest import ( TEST_GATEWAY_URL, TEST_SESSION_URN, AsyncFakeResponse, + jsonrpc_result, chat_tool_response, invoke_envelope, make_async_gateway, make_async_parent, + session_create_response, tool_result, ) @@ -62,7 +73,8 @@ def test_invoke_and_invoke_one(): assert output.answer == 7 request = _sent_request(gateway) assert request.url.endswith("/tools/invoke") - assert request.headers[SESSION_ID_HEADER] == TEST_SESSION_URN + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" assert _sent_payload(gateway)["tools"][0]["tool"] == "web_search" @@ -98,11 +110,42 @@ def test_mcp_transport_parses_sse_response(): gateway = AsyncGatewayResources( parent, gateway_endpoint=TEST_GATEWAY_URL, - transport=AsyncMCPTransport(proxy, session_id=TEST_SESSION_URN), + transport=AsyncMCPTransport( + proxy, session_id=TEST_SESSION_URN, actor_id="actor-123" + ), ) assert _run(gateway.tools.list())[0].name == "action_search" +def test_session_create_uses_public_api_and_actor_header(): + parent = make_async_parent( + [ + AsyncFakeResponse(201, session_create_response()), + AsyncFakeResponse(200, jsonrpc_result({"tools": []})), + ] + ) + operations = AsyncSessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + async def scenario(): + session = await operations.create("actor-123", name="named") + await session.tools.list(include_all=True) + return session + + session = _run(scenario()) + create_request = parent._client._pipeline.calls[0].request + assert create_request.url.endswith("/v2/action-gateway/sessions") + assert json.loads(create_request.content) == { + "actor_id": "actor-123", + "name": "named", + "policy": {"defaultAction": "allow"}, + } + tool_request = parent._client._pipeline.calls[1].request + assert tool_request.url == session.url + assert tool_request.headers[SESSION_ID_HEADER] == "test-session" + assert tool_request.headers[ACTOR_ID_HEADER] == "actor-123" + assert session.actor_id == "actor-123" + + def test_tools_callable_and_handle_tool_calls(): envelope = invoke_envelope(output={"ok": True}) gateway = make_async_gateway( diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 219bc0d6..d98867f9 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -14,25 +14,28 @@ from azure.core.exceptions import ResourceNotFoundError from pydo.gateway import ( + ACTOR_ID_HEADER, SESSION_ID_HEADER, SessionsOperations, normalize_permissions, ) from pydo.gateway.session import serialize_policy_json +from pydo.gateway.transport import _META_TOOL_DEFINITIONS from .conftest import ( TEST_GATEWAY_URL, TEST_SESSION_URN, FakeResponse, + call_result, chat_tool_response, - invoke_envelope, + jsonrpc_result, make_parent, session_create_response, ) def test_normalize_permissions_defaults_to_allow(): - assert normalize_permissions(None) == {"defaultAction": "allow", "rules": []} + assert normalize_permissions(None) == {"defaultAction": "allow"} def test_normalize_permissions_accepts_snake_case(): @@ -40,7 +43,7 @@ def test_normalize_permissions_accepts_snake_case(): { "default_action": "ask", "rules": [ - {"toolbelt": "read-only@1.2.3", "action": "allow"}, + {"tool": "toolbelt:read-only@1.2.3", "action": "allow"}, {"tool": "gmail", "action": "deny"}, ], } @@ -48,24 +51,29 @@ def test_normalize_permissions_accepts_snake_case(): assert policy == { "defaultAction": "ask", "rules": [ - {"toolbelt": "read-only@1.2.3", "action": "allow"}, + {"tool": "toolbelt:read-only@1.2.3", "action": "allow"}, {"tool": "gmail", "action": "deny"}, ], } -def test_normalize_permissions_requires_tool_or_toolbelt(): - with pytest.raises(ValueError, match="tool or toolbelt"): +def test_normalize_permissions_requires_tool(): + with pytest.raises(ValueError, match="requires tool"): normalize_permissions({"rules": [{"action": "allow"}]}) +def test_normalize_permissions_rejects_legacy_toolbelt_key(): + with pytest.raises(ValueError, match="toolbelt permissions are no longer"): + normalize_permissions({"rules": [{"toolbelt": "read-only@1.2.3"}]}) + + def test_serialize_policy_json(): assert json.loads(serialize_policy_json(None))["defaultAction"] == "allow" -def test_sessions_create_requires_end_user_id(): +def test_sessions_create_requires_actor_id(): ops = SessionsOperations(make_parent([]), gateway_endpoint=TEST_GATEWAY_URL) - with pytest.raises(ValueError, match="end_user_id"): + with pytest.raises(ValueError, match="actor_id"): ops.create("") @@ -82,11 +90,12 @@ def test_sessions_create_404_has_route_diagnostic(): ops.create("user-123") -def test_sessions_create_posts_to_do_api_and_binds_rest(): +def test_sessions_create_posts_to_do_api_and_binds_returned_mcp_url(): parent = make_parent( [ FakeResponse(200, session_create_response()), - FakeResponse(200, invoke_envelope(output={"ok": True})), + FakeResponse(200, jsonrpc_result({"tools": _META_TOOL_DEFINITIONS})), + FakeResponse(200, jsonrpc_result(call_result(structured={"ok": True}))), ] ) ops = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) @@ -96,24 +105,24 @@ def test_sessions_create_posts_to_do_api_and_binds_rest(): assert create_req.method == "POST" assert create_req.url.endswith("/v2/action-gateway/sessions") body = json.loads(create_req.content) - assert body["end_user_id"] == "user-123" - assert json.loads(body["policy_json"]) == { - "defaultAction": "allow", - "rules": [], - } + assert body["actor_id"] == "user-123" + assert "end_user_id" not in body + assert body["policy"] == {"defaultAction": "allow"} assert body["name"].startswith("pydo-session-") assert session.session_urn == TEST_SESSION_URN - assert session.end_user_id == "user-123" + assert session.actor_id == "user-123" assert session.url == "https://actions.do-ai-test.run/mcp/session/test-session" tools = session.tools() assert [t["function"]["name"] for t in tools][:1] == ["action_search"] messages = session.handle_tool_calls(chat_tool_response()) - invoke_req = parent._client._pipeline.calls[1].request - assert invoke_req.url.endswith("/tools/invoke") - assert invoke_req.headers[SESSION_ID_HEADER] == TEST_SESSION_URN + invoke_req = parent._client._pipeline.calls[2].request + assert invoke_req.url == session.url + assert invoke_req.headers[SESSION_ID_HEADER] == "test-session" + assert invoke_req.headers[ACTOR_ID_HEADER] == "user-123" + assert json.loads(invoke_req.content)["method"] == "tools/call" assert messages[0]["role"] == "tool" @@ -122,7 +131,7 @@ def test_sessions_create_with_permissions_and_name(): [ FakeResponse( 200, - session_create_response(name="named", end_user_id="u1"), + session_create_response(name="named"), ) ] ) @@ -137,7 +146,7 @@ def test_sessions_create_with_permissions_and_name(): ) body = json.loads(parent._client._pipeline.calls[0].request.content) assert body["name"] == "named" - assert json.loads(body["policy_json"]) == { + assert body["policy"] == { "defaultAction": "deny", "rules": [{"tool": "web_search", "action": "allow"}], } diff --git a/tests/gateway/test_transport.py b/tests/gateway/test_transport.py index 723e7601..b503ad0f 100644 --- a/tests/gateway/test_transport.py +++ b/tests/gateway/test_transport.py @@ -16,6 +16,7 @@ from pydo.custom_extensions import _BaseURLProxy from pydo.gateway import ( + ACTOR_ID_HEADER, GatewayProtocolError, GatewayResources, GatewayToolError, @@ -86,7 +87,8 @@ def test_list_tools_include_all_hits_rest_catalog(): request = sent_request(gateway) assert request.method == "GET" assert request.url.endswith("/tools") - assert request.headers[SESSION_ID_HEADER] == TEST_SESSION_URN + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" assert tools[0].name == "web_search" @@ -103,7 +105,8 @@ def test_search_posts_rest_and_unwraps_tool_result(): request = sent_request(gateway) assert request.method == "POST" assert request.url.endswith("/tools/search") - assert request.headers[SESSION_ID_HEADER] == TEST_SESSION_URN + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" payload = sent_payload(gateway) assert payload["queries"] == [{"use_case": "search the web"}] assert result.results[0].use_case == "x" @@ -200,7 +203,7 @@ def test_mcp_transport_still_works_with_session_header(): [FakeResponse(200, jsonrpc_result({"tools": [{"name": "action_search"}]}))] ) proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) - transport = MCPTransport(proxy, session_id=TEST_SESSION_URN) + transport = MCPTransport(proxy, session_id=TEST_SESSION_URN, actor_id="actor-123") gateway = GatewayResources( parent, gateway_endpoint=TEST_GATEWAY_URL, @@ -209,7 +212,8 @@ def test_mcp_transport_still_works_with_session_header(): tools = gateway.tools.list() request = sent_request(gateway) assert request.url.endswith("/mcp/meta") - assert request.headers[SESSION_ID_HEADER] == TEST_SESSION_URN + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" assert tools[0].name == "action_search" @@ -228,7 +232,9 @@ def test_mcp_transport_parses_sse_response(): gateway = GatewayResources( parent, gateway_endpoint=TEST_GATEWAY_URL, - transport=MCPTransport(proxy, session_id=TEST_SESSION_URN), + transport=MCPTransport( + proxy, session_id=TEST_SESSION_URN, actor_id="actor-123" + ), ) assert gateway.tools.list()[0].name == "action_search" @@ -236,7 +242,7 @@ def test_mcp_transport_parses_sse_response(): def test_mcp_jsonrpc_error_raises_protocol_error(): parent = make_parent([FakeResponse(200, jsonrpc_error(-32601, "method not found"))]) proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) - transport = MCPTransport(proxy, session_id=TEST_SESSION_URN) + transport = MCPTransport(proxy, session_id=TEST_SESSION_URN, actor_id="actor-123") gateway = GatewayResources( parent, gateway_endpoint=TEST_GATEWAY_URL, transport=transport ) @@ -259,7 +265,7 @@ def test_mcp_is_error_raises_gateway_tool_error(): [FakeResponse(200, jsonrpc_result(call_result(structured, is_error=True)))] ) proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) - transport = MCPTransport(proxy, session_id=TEST_SESSION_URN) + transport = MCPTransport(proxy, session_id=TEST_SESSION_URN, actor_id="actor-123") gateway = GatewayResources( parent, gateway_endpoint=TEST_GATEWAY_URL, transport=transport ) From 22f1d4720e39b35d7edc6ee93a6ee0d098457f1a Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Mon, 27 Jul 2026 10:53:04 -0500 Subject: [PATCH 09/19] update --- examples/gateway/create_session.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 examples/gateway/create_session.py diff --git a/examples/gateway/create_session.py b/examples/gateway/create_session.py new file mode 100644 index 00000000..09bf5211 --- /dev/null +++ b/examples/gateway/create_session.py @@ -0,0 +1,19 @@ +"""Create an Action Gateway session and print its MCP URL. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), +) + +print(session.url) From 6e82c71a122877ff965f1cb533c97d029950b694 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Mon, 27 Jul 2026 10:53:57 -0500 Subject: [PATCH 10/19] update --- examples/gateway/function_calling_loop.py | 26 ++++------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/examples/gateway/function_calling_loop.py b/examples/gateway/function_calling_loop.py index 54861c7b..4ba8f560 100644 --- a/examples/gateway/function_calling_loop.py +++ b/examples/gateway/function_calling_loop.py @@ -10,7 +10,6 @@ PROMPT """ -import json import os from pydo.action_gateway import ActionGatewayClient @@ -39,29 +38,12 @@ break messages.append(dict(message)) - tool_names = {} - for tool_call in message["tool_calls"]: - function = tool_call["function"] - tool_names[tool_call["id"]] = function["name"] - print(f"\n[tool call: {function['name']} id={tool_call['id']}]") - try: - arguments = json.loads(function["arguments"]) - print(json.dumps(arguments, indent=2)) - except (TypeError, ValueError): - print(function["arguments"]) - tool_messages = session.handle_tool_calls(response) - if any(name != "action_search" for name in tool_names.values()): + if any( + tool_call["function"]["name"] != "action_search" + for tool_call in message["tool_calls"] + ): tool_choice = "auto" - for tool_message in tool_messages: - tool_call_id = tool_message.get("tool_call_id", "unknown") - tool_name = tool_names.get(tool_call_id, "unknown") - print(f"[tool result: {tool_name} id={tool_call_id}]") - try: - result = json.loads(tool_message["content"]) - print(json.dumps(result, indent=2)) - except (TypeError, ValueError): - print(tool_message["content"]) messages.extend(tool_messages) print("\nFinal answer:\n") From d5bd2f221db48b5c0578cbe698dc95959de821c1 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Mon, 27 Jul 2026 11:02:54 -0500 Subject: [PATCH 11/19] approval flow --- docs/gateway-sdk-experience.md | 8 ++++ examples/gateway/approval_flow.py | 58 +++++++++++++++++++++++ src/pydo/aio/gateway/custom_operations.py | 23 +++++++++ src/pydo/aio/gateway/session.py | 5 ++ src/pydo/gateway/session.py | 5 ++ src/pydo/gateway/transport.py | 23 +++++++++ tests/gateway/test_async_gateway.py | 24 ++++++++++ tests/gateway/test_session.py | 21 ++++++++ 8 files changed, 167 insertions(+) create mode 100644 examples/gateway/approval_flow.py diff --git a/docs/gateway-sdk-experience.md b/docs/gateway-sdk-experience.md index febcadc3..3b1e2a4b 100644 --- a/docs/gateway-sdk-experience.md +++ b/docs/gateway-sdk-experience.md @@ -71,6 +71,14 @@ result = session.code.execute("print(sum(range(10)))") These calls use JSON-RPC over the `mcpUrl` returned by session creation, with `X-Session-Id` and `X-Actor-Id`. +For policies that require approval, approve a pending invocation by ID: + +```python +session.approve(approval_id) +``` + +See `examples/gateway/approval_flow.py` for a complete request, approval, and retry flow. + --- ## 3. Using tools with a model diff --git a/examples/gateway/approval_flow.py b/examples/gateway/approval_flow.py new file mode 100644 index 00000000..ff39209e --- /dev/null +++ b/examples/gateway/approval_flow.py @@ -0,0 +1,58 @@ +"""Request and approve a tool invocation in one session. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID + TOOL_NAME default: exa_web_search +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + + +def find_approval_id(value): + """Find an approval ID in a gateway result envelope.""" + if isinstance(value, dict): + for key in ("approval_id", "approvalId"): + if value.get(key): + return value[key] + for nested in value.values(): + approval_id = find_approval_id(nested) + if approval_id: + return approval_id + elif isinstance(value, list): + for nested in value: + approval_id = find_approval_id(nested) + if approval_id: + return approval_id + return None + + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={"default_action": "ask"}, +) + +tool_name = os.environ.get("TOOL_NAME", "exa_web_search") +arguments = {"query": "DigitalOcean", "max_results": 3} +result = session.tools.invoke( + [{"tool": tool_name, "arguments": arguments}], + rationale="demonstrate SDK approval flow", +) + +approval_id = find_approval_id(result) +if not approval_id: + raise RuntimeError(f"invocation did not request approval: {result!r}") + +input(f"Approve {approval_id}? Press Enter to continue...") +session.approve(approval_id) + +result = session.tools.invoke( + [{"tool": tool_name, "arguments": arguments}], + rationale="retry after approval", +) +print(result) diff --git a/src/pydo/aio/gateway/custom_operations.py b/src/pydo/aio/gateway/custom_operations.py index 2659f8a3..8a7c7e2a 100644 --- a/src/pydo/aio/gateway/custom_operations.py +++ b/src/pydo/aio/gateway/custom_operations.py @@ -9,6 +9,7 @@ import itertools from typing import Any, Dict, List, Optional, Sequence, Union +from urllib.parse import quote, urlsplit from azure.core.rest import HttpRequest @@ -64,6 +65,9 @@ async def call_tool( ) -> Any: raise NotImplementedError + async def approve(self, approval_id: str) -> Any: + raise NotImplementedError + class AsyncMCPTransport(AsyncGatewayTransport): """Async JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" @@ -138,6 +142,25 @@ async def call_tool( ) return _unwrap_call_result(result) + async def approve(self, approval_id: str) -> Any: + if not approval_id or not str(approval_id).strip(): + raise ValueError("approval_id is required") + endpoint = urlsplit(self.endpoint_url or self._client._base_url) + approval_id = quote(str(approval_id).strip(), safe="") + url = f"{endpoint.scheme}://{endpoint.netloc}/approvals/{approval_id}" + request = HttpRequest( + "POST", + url, + headers={**self._headers(), "Accept": "application/json"}, + json={"decision": "approve"}, + ) + pipeline_response = await self._client._pipeline.run(request) + response = pipeline_response.http_response + body = await response.read() + if response.status_code not in (200, 201, 202, 204): + _raise_gateway_http_error(response) + return _wrap(_parse_json_body(body)) if body else None + class AsyncRESTTransport(AsyncGatewayTransport): """Async REST transport; requires ``session_id`` via ``X-Session-Id``.""" diff --git a/src/pydo/aio/gateway/session.py b/src/pydo/aio/gateway/session.py index 6e7b448e..87bd9742 100644 --- a/src/pydo/aio/gateway/session.py +++ b/src/pydo/aio/gateway/session.py @@ -63,6 +63,7 @@ def __init__( self._mcp_url = mcp_url self.tools = tools self.code = code + self._transport = tools._transport self.provider = provider self.raw = raw or {} @@ -90,6 +91,10 @@ async def execute_tool_calls( ) -> List[Any]: return await async_execute_tool_calls(calls, self.tools, rationale=rationale) + async def approve(self, approval_id: str) -> Any: + """Approve a pending tool invocation for this session.""" + return await self._transport.approve(approval_id) + def __repr__(self) -> str: # pragma: no cover return f"" diff --git a/src/pydo/gateway/session.py b/src/pydo/gateway/session.py index 7aec90ff..d09d10f4 100644 --- a/src/pydo/gateway/session.py +++ b/src/pydo/gateway/session.py @@ -104,6 +104,7 @@ def __init__( self._mcp_url = mcp_url self.tools = tools self.code = code + self._transport = tools._transport self.provider = provider self.raw = raw or {} @@ -134,6 +135,10 @@ def execute_tool_calls( """Execute pre-extracted tool calls; return raw outputs.""" return execute_tool_calls(calls, self.tools, rationale=rationale) + def approve(self, approval_id: str) -> Any: + """Approve a pending tool invocation for this session.""" + return self._transport.approve(approval_id) + def __repr__(self) -> str: # pragma: no cover - debug aid return f"" diff --git a/src/pydo/gateway/transport.py b/src/pydo/gateway/transport.py index 453d70b7..0b644ae0 100644 --- a/src/pydo/gateway/transport.py +++ b/src/pydo/gateway/transport.py @@ -18,6 +18,7 @@ import json as _json import os from typing import Any, Dict, List, Optional +from urllib.parse import quote, urlsplit from azure.core.exceptions import ( ClientAuthenticationError, @@ -350,6 +351,9 @@ def list_tools(self, *, meta: bool) -> List[Any]: def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: raise NotImplementedError + def approve(self, approval_id: str) -> Any: + raise NotImplementedError + class RESTTransport(GatewayTransport): """REST over ``/tools``, ``/tools/search``, ``/tools/invoke``, ``/code/execute``. @@ -496,6 +500,25 @@ def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: ) return _unwrap_call_result(result) + def approve(self, approval_id: str) -> Any: + if not approval_id or not str(approval_id).strip(): + raise ValueError("approval_id is required") + endpoint = urlsplit(self.endpoint_url or self._client._base_url) + approval_id = quote(str(approval_id).strip(), safe="") + url = f"{endpoint.scheme}://{endpoint.netloc}/approvals/{approval_id}" + request = HttpRequest( + "POST", + url, + headers={**self._headers(), "Accept": "application/json"}, + json={"decision": "approve"}, + ) + pipeline_response = self._client._pipeline.run(request) + response = pipeline_response.http_response + body = response.text() if hasattr(response, "text") else response.body() + if response.status_code not in (200, 201, 202, 204): + _raise_gateway_http_error(response) + return _wrap(_parse_json_body(body)) if body else None + __all__ = [ "GatewayTransport", diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index 57d544c7..9b435fca 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -146,6 +146,30 @@ async def scenario(): assert session.actor_id == "actor-123" +def test_session_approve_posts_to_gateway(): + parent = make_async_parent( + [ + AsyncFakeResponse(201, session_create_response()), + AsyncFakeResponse(200, {"status": "approved"}), + ] + ) + operations = AsyncSessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + async def scenario(): + session = await operations.create("actor-123") + result = await session.approve("approval-123") + return session, result + + session, result = _run(scenario()) + request = parent._client._pipeline.calls[1].request + assert request.url == f"{TEST_GATEWAY_URL}/approvals/approval-123" + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" + assert json.loads(request.content) == {"decision": "approve"} + assert result.status == "approved" + assert session.actor_id == "actor-123" + + def test_tools_callable_and_handle_tool_calls(): envelope = invoke_envelope(output={"ok": True}) gateway = make_async_gateway( diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index d98867f9..f8dd825b 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -151,3 +151,24 @@ def test_sessions_create_with_permissions_and_name(): "rules": [{"tool": "web_search", "action": "allow"}], } assert session.name == "named" + + +def test_session_approve_posts_to_gateway(): + parent = make_parent( + [ + FakeResponse(200, session_create_response()), + FakeResponse(200, {"status": "approved"}), + ] + ) + session = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL).create( + "user-123" + ) + + result = session.approve("approval-123") + + request = parent._client._pipeline.calls[1].request + assert request.url == f"{TEST_GATEWAY_URL}/approvals/approval-123" + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "user-123" + assert json.loads(request.content) == {"decision": "approve"} + assert result.status == "approved" From 6a57a30029caf62eab0cbadfaaf8bd2896b9c428 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 29 Jul 2026 13:34:33 -0500 Subject: [PATCH 12/19] update --- docs/gateway-sdk-experience.md | 37 +++++--- examples/gateway/approval_flow.py | 96 ++++++++++++++------- examples/gateway/async_invoke_tools.py | 11 ++- examples/gateway/create_session.py | 6 ++ examples/gateway/execute_code.py | 4 + examples/gateway/function_calling_loop.py | 7 ++ examples/gateway/invoke_tools.py | 16 +++- examples/gateway/list_tools.py | 5 +- examples/gateway/messages_tool_use.py | 7 ++ examples/gateway/responses_tool_use.py | 6 +- examples/gateway/toolbelt_policy.py | 2 +- openapi/action-gateway-toolbelts.patch | 10 +-- src/pydo/aio/gateway/custom_operations.py | 29 ++++++- src/pydo/aio/gateway/session.py | 21 ++++- src/pydo/gateway/__init__.py | 7 +- src/pydo/gateway/custom_models.py | 4 + src/pydo/gateway/custom_operations.py | 5 +- src/pydo/gateway/providers.py | 12 ++- src/pydo/gateway/session.py | 45 +++++++--- src/pydo/gateway/transport.py | 36 ++++++-- src/pydo/operations/_operations.py | 12 +-- tests/gateway/conftest.py | 12 ++- tests/gateway/test_action_gateway_client.py | 2 +- tests/gateway/test_async_gateway.py | 31 ++++++- tests/gateway/test_session.py | 80 +++++++++++++++-- 25 files changed, 393 insertions(+), 110 deletions(-) diff --git a/docs/gateway-sdk-experience.md b/docs/gateway-sdk-experience.md index 3b1e2a4b..d2429268 100644 --- a/docs/gateway-sdk-experience.md +++ b/docs/gateway-sdk-experience.md @@ -3,7 +3,7 @@ **Audience:** internal alignment on the developer experience of the Action Gateway surface in `pydo`. **Status:** proposal / preview. Feedback welcome — nothing here is final. -The Action Gateway gives models access to a large catalog of third-party tools plus a sandboxed Python runtime. Usage is **session-first**: create a session on the DigitalOcean API, then call tools over REST on `actions.do-ai.run` with that session. +The Action Gateway gives models access to a large catalog of third-party tools plus a sandboxed Python runtime. Usage is **session-first**: create a session on the DigitalOcean API, then call tools through the returned MCP URL. --- @@ -21,11 +21,11 @@ client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) session = client.session.create( actor_id="user-123", # required - # permissions optional — defaults to allow-all + # permissions optional — defaults to ask ) ``` -`actor_id` is required. If you omit `permissions`, the SDK creates a default policy of `{"defaultAction": "allow"}`. Optional permissions: +`actor_id` is required. If you omit `permissions`, the SDK creates a default policy of `{"defaultAction": "ask"}`. Optional permissions: ```python session = client.session.create( @@ -33,14 +33,26 @@ session = client.session.create( permissions={ "default_action": "ask", "rules": [ - {"tool": "toolbelt:read-only@1.2.3", "action": "allow"}, + {"tool": "toolbelt:read-only@1", "action": "allow"}, {"tool": "gmail", "action": "allow"}, ], }, ) ``` -Session create hits `POST /v2/action-gateway/sessions` on `api.digitalocean.com` with `name`, `policy`, and `actor_id`. The same actor is sent as `X-Actor-Id` on gateway requests. Tool calls go to the gateway host (`https://actions.do-ai.run` by default; override with `gateway_endpoint=` or `PYDO_GATEWAY_ENDPOINT`). +Session create hits `POST /v2/action-gateway/sessions` on `api.digitalocean.com` with `name`, `policy`, and `actor_id`. It can also send `tools` (omitted means all, an empty list means none) and opaque `config`, including `preloadTools`. Tool calls use the `mcpUrl` returned by the API. The same actor is sent as `X-Actor-Id` on gateway requests. + +```python +session = client.session.create( + actor_id="user-123", + tools=["exa_web_search@v1"], + config={"preloadTools": ["exa_web_search@v1"]}, + permissions={ + "default_action": "ask", + "rules": [{"tool": "exa_web_search", "action": "allow"}], + }, +) +``` `session.url` is the session-pinned MCP URL for external MCP clients: @@ -54,7 +66,7 @@ https://actions.do-ai.run/mcp/session/ ```python results = session.tools.search("search the web for recent news") -catalog = session.tools.list(include_all=True) +session_tools = session.tools.list(include_all=True) output = session.tools.invoke_one( "exa_web_search", @@ -133,7 +145,7 @@ tools = session.tools() ## 4. Meta-tools vs. concrete tools -`session.tools()` defaults to the three meta-tools (`action_search`, `action_invoke`, `action_code`). For a fixed surface: +`session.tools()` defaults to the three meta-tools (`action_search`, `action_invoke`, `action_code`). With `config.preloadTools`, request every tool exposed on this session MCP endpoint or select by name: ```python tools = session.tools(include_all=True) @@ -183,7 +195,7 @@ session = client.session.create( ) ``` -Toolbelt creation maps to `POST /v2/action-gateway/toolbelts`. The response exposes both `toolbelt.reference` and the shorter `toolbelt.ref` alias. +Toolbelt creation maps to `POST /v2/toolbelts`. The response exposes both `toolbelt.reference` and the shorter `toolbelt.ref` alias. --- @@ -205,6 +217,11 @@ response = client.responses.create( tool_outputs = session.handle_tool_calls(response) ``` +The Responses API can also connect to `session.url` directly when its MCP tool +surface supports remote MCP servers. Gateway policy approval remains separate +from model-provider approval: use `session.approve(approval_id)` or +`session.deny(approval_id)`, then retry the tool call. + --- ## 7. Async @@ -224,6 +241,6 @@ async with ActionGatewayClient(token=token) as client: ## 8. Design notes - **Session-first.** Bare gateway calls without a session are unsupported. -- **REST for SDK execution.** MCP remains available via `session.url` for external clients. +- **MCP for SDK execution.** `session.url` is the same returned MCP endpoint used by the SDK. - **Provider pattern.** Chat Completions / Messages / Responses formatting stays in small provider classes. -- **Same DO token** for session create (public API) and gateway REST (actions host). +- **Same DO token** for session create and the returned MCP endpoint. diff --git a/examples/gateway/approval_flow.py b/examples/gateway/approval_flow.py index ff39209e..51ea2583 100644 --- a/examples/gateway/approval_flow.py +++ b/examples/gateway/approval_flow.py @@ -1,58 +1,94 @@ -"""Request and approve a tool invocation in one session. +"""Approve Chat Completions tool calls through an Action Gateway session. Required env: DIGITALOCEAN_TOKEN Optional env: ACTOR_ID - TOOL_NAME default: exa_web_search + MODEL + PROMPT """ +import json import os from pydo.action_gateway import ActionGatewayClient -def find_approval_id(value): - """Find an approval ID in a gateway result envelope.""" +def find_approval_ids(value): + """Find approval IDs in gateway tool-result messages.""" + approval_ids = [] if isinstance(value, dict): for key in ("approval_id", "approvalId"): if value.get(key): - return value[key] + approval_ids.append(value[key]) for nested in value.values(): - approval_id = find_approval_id(nested) - if approval_id: - return approval_id + approval_ids.extend(find_approval_ids(nested)) elif isinstance(value, list): for nested in value: - approval_id = find_approval_id(nested) - if approval_id: - return approval_id - return None + approval_ids.extend(find_approval_ids(nested)) + elif isinstance(value, str): + try: + approval_ids.extend(find_approval_ids(json.loads(value))) + except json.JSONDecodeError: + pass + return approval_ids -client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"], timeout=30) +print("Creating Action Gateway session...") session = client.session.create( actor_id=os.environ.get("ACTOR_ID", "example-user"), - permissions={"default_action": "ask"}, + permissions={ + "default_action": "ask", + "rules": [{"tool": "action_search", "action": "allow"}], + }, ) -tool_name = os.environ.get("TOOL_NAME", "exa_web_search") -arguments = {"query": "DigitalOcean", "max_results": 3} -result = session.tools.invoke( - [{"tool": tool_name, "arguments": arguments}], - rationale="demonstrate SDK approval flow", -) +model = os.environ.get("MODEL", "openai-gpt-4o") +messages = [ + { + "role": "user", + "content": os.environ.get( + "PROMPT", + "Search for the latest DigitalOcean news and summarize it.", + ), + } +] +tools = session.tools() +tool_choice = "required" -approval_id = find_approval_id(result) -if not approval_id: - raise RuntimeError(f"invocation did not request approval: {result!r}") +while True: + print(f"Requesting next tool call from {model}...") + response = client.chat.completions.create( + model=model, + messages=messages, + tools=tools, + tool_choice=tool_choice, + parallel_tool_calls=False, + ) + message = response.choices[0].message + if not message.get("tool_calls"): + break -input(f"Approve {approval_id}? Press Enter to continue...") -session.approve(approval_id) + print("Executing requested gateway tool...") + tool_messages = session.handle_tool_calls(response) + approval_ids = list(dict.fromkeys(find_approval_ids(tool_messages))) + for approval_id in approval_ids: + input(f"Approve {approval_id}? Press Enter to continue...") + session.approve(approval_id) -result = session.tools.invoke( - [{"tool": tool_name, "arguments": arguments}], - rationale="retry after approval", -) -print(result) + if approval_ids: + print("Retrying approved gateway tool...") + tool_messages = session.handle_tool_calls(response) + + messages.append(dict(message)) + messages.extend(tool_messages) + if any( + tool_call["function"]["name"] != "action_search" + for tool_call in message["tool_calls"] + ): + tool_choice = "auto" + +print("\nFinal answer:\n") +print(message.get("content")) diff --git a/examples/gateway/async_invoke_tools.py b/examples/gateway/async_invoke_tools.py index e4026b8d..bdc2aff5 100644 --- a/examples/gateway/async_invoke_tools.py +++ b/examples/gateway/async_invoke_tools.py @@ -18,14 +18,21 @@ async def main() -> None: client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) session = await client.session.create( actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "execute_code", "action": "allow"}, + ], + }, ) tools = await session.tools.list(include_all=True) - print("catalog:", [tool.name for tool in tools]) + print("session tools:", [tool.name for tool in tools]) print("MCP URL:", session.url) output = await session.tools.invoke_one( - "web_search", {"query": "DigitalOcean Gradient", "max_results": 2} + "exa_web_search", {"query": "DigitalOcean Gradient", "max_results": 2} ) print("web_search output:", str(output)[:200]) diff --git a/examples/gateway/create_session.py b/examples/gateway/create_session.py index 09bf5211..1481917d 100644 --- a/examples/gateway/create_session.py +++ b/examples/gateway/create_session.py @@ -14,6 +14,12 @@ client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) session = client.session.create( actor_id=os.environ.get("ACTOR_ID", "example-user"), + tools=["exa_web_search@v1"], + config={"preloadTools": ["exa_web_search@v1"]}, + permissions={ + "default_action": "ask", + "rules": [{"tool": "exa_web_search", "action": "allow"}], + }, ) print(session.url) diff --git a/examples/gateway/execute_code.py b/examples/gateway/execute_code.py index a12a3739..886bb8a9 100644 --- a/examples/gateway/execute_code.py +++ b/examples/gateway/execute_code.py @@ -15,6 +15,10 @@ client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) session = client.session.create( actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [{"tool": "execute_code", "action": "allow"}], + }, ) result = session.code.execute( diff --git a/examples/gateway/function_calling_loop.py b/examples/gateway/function_calling_loop.py index 4ba8f560..d4770909 100644 --- a/examples/gateway/function_calling_loop.py +++ b/examples/gateway/function_calling_loop.py @@ -17,6 +17,13 @@ client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) session = client.session.create( actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "exa_web_fetch", "action": "allow"}, + ], + }, ) model = os.environ.get("MODEL", "openai-gpt-5.4") diff --git a/examples/gateway/invoke_tools.py b/examples/gateway/invoke_tools.py index 3ffdfb69..8415bf5a 100644 --- a/examples/gateway/invoke_tools.py +++ b/examples/gateway/invoke_tools.py @@ -15,15 +15,25 @@ client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) session = client.session.create( actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "exa_web_fetch", "action": "allow"}, + ], + }, ) envelope = session.tools.invoke( [ { - "tool": "web_search", + "tool": "exa_web_search", "arguments": {"query": "DigitalOcean Gradient", "max_results": 3}, }, - {"tool": "web_fetch", "arguments": {"url": "https://www.digitalocean.com"}}, + { + "tool": "exa_web_fetch", + "arguments": {"url": "https://www.digitalocean.com"}, + }, ], rationale="demonstrate parallel tool invocation", ) @@ -39,6 +49,6 @@ print(f" error ({error.get('class')}): {error.get('message')}") output = session.tools.invoke_one( - "web_search", {"query": "MCP protocol", "max_results": 1} + "exa_web_search", {"query": "MCP protocol", "max_results": 1} ) print("\ninvoke_one output:", str(output)[:200]) diff --git a/examples/gateway/list_tools.py b/examples/gateway/list_tools.py index 6c5611b3..0ac1b288 100644 --- a/examples/gateway/list_tools.py +++ b/examples/gateway/list_tools.py @@ -1,7 +1,8 @@ """List Action Gateway tools for a session. By default the session exposes three meta-tools (action_search, -action_invoke, action_code). Pass include_all=True for the concrete catalog. +action_invoke, action_code). Pass include_all=True to include tools configured +through config.preloadTools. Required env: DIGITALOCEAN_TOKEN @@ -25,6 +26,6 @@ for tool in session.tools.list(): print(f" {tool.name}: {tool.get('description', '')[:80]}") -print("\nFull concrete catalog:") +print("\nAll tools exposed by this session MCP endpoint:") for tool in session.tools.list(include_all=True): print(f" {tool.name}: {tool.get('description', '')[:80]}") diff --git a/examples/gateway/messages_tool_use.py b/examples/gateway/messages_tool_use.py index 566c6249..2dec6211 100644 --- a/examples/gateway/messages_tool_use.py +++ b/examples/gateway/messages_tool_use.py @@ -47,6 +47,13 @@ def _print_final_message(response) -> None: ) session = client.session.create( actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "exa_web_fetch", "action": "allow"}, + ], + }, ) model = os.environ.get("MODEL", "claude-opus-4-6") diff --git a/examples/gateway/responses_tool_use.py b/examples/gateway/responses_tool_use.py index 005b11e7..1f7e084b 100644 --- a/examples/gateway/responses_tool_use.py +++ b/examples/gateway/responses_tool_use.py @@ -19,13 +19,17 @@ ) session = client.session.create( actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [{"tool": "exa_web_search", "action": "allow"}], + }, ) response = client.responses.create( model=os.environ.get("MODEL", "openai-gpt-4o"), input=os.environ.get( "PROMPT", - "What DigitalOcean Droplet sizes are available in NYC3?", + "Find the latest DigitalOcean news and summarize it.", ), tools=session.tools(), ) diff --git a/examples/gateway/toolbelt_policy.py b/examples/gateway/toolbelt_policy.py index 07bf16a9..b1a2dc77 100644 --- a/examples/gateway/toolbelt_policy.py +++ b/examples/gateway/toolbelt_policy.py @@ -19,7 +19,7 @@ "default_action": "ask", "rules": [ {"tool": "toolbelt:search-toolbelt@1", "action": "allow"}, - {"tool": "digitalocean_size-list", "action": "allow"}, + {"tool": "exa_web_search", "action": "allow"}, ], }, ) diff --git a/openapi/action-gateway-toolbelts.patch b/openapi/action-gateway-toolbelts.patch index f3f2647c..c89cfd96 100644 --- a/openapi/action-gateway-toolbelts.patch +++ b/openapi/action-gateway-toolbelts.patch @@ -16,23 +16,23 @@ index b8c29b4..6bbe488 100644 - Serverless Inference paths: -+ /v2/action-gateway/toolbelts: ++ /v2/toolbelts: + get: + $ref: "resources/action_gateway/toolbelts_list.yml" + post: + $ref: "resources/action_gateway/toolbelts_create.yml" + -+ /v2/action-gateway/toolbelts/{name}: ++ /v2/toolbelts/{name}: + get: + $ref: "resources/action_gateway/toolbelts_get.yml" + delete: + $ref: "resources/action_gateway/toolbelts_delete.yml" + -+ /v2/action-gateway/toolbelts/{name}/tools/add: ++ /v2/toolbelts/{name}/tools/add: + post: + $ref: "resources/action_gateway/toolbelts_add_tools.yml" + -+ /v2/action-gateway/toolbelts/{name}/tools/remove: ++ /v2/toolbelts/{name}/tools/remove: + post: + $ref: "resources/action_gateway/toolbelts_remove_tools.yml" + @@ -392,7 +392,7 @@ index 0000000..ea6a984 + content: + application/json: + schema: -+ type: object ++ $ref: 'models.yml#/toolbelt_response' + '401': + $ref: '../../shared/responses/unauthorized.yml' + '404': diff --git a/src/pydo/aio/gateway/custom_operations.py b/src/pydo/aio/gateway/custom_operations.py index 8a7c7e2a..aa7634a0 100644 --- a/src/pydo/aio/gateway/custom_operations.py +++ b/src/pydo/aio/gateway/custom_operations.py @@ -65,9 +65,12 @@ async def call_tool( ) -> Any: raise NotImplementedError - async def approve(self, approval_id: str) -> Any: + async def decide_approval(self, approval_id: str, decision: str) -> Any: raise NotImplementedError + async def approve(self, approval_id: str) -> Any: + return await self.decide_approval(approval_id, "approve") + class AsyncMCPTransport(AsyncGatewayTransport): """Async JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" @@ -142,9 +145,11 @@ async def call_tool( ) return _unwrap_call_result(result) - async def approve(self, approval_id: str) -> Any: + async def decide_approval(self, approval_id: str, decision: str) -> Any: if not approval_id or not str(approval_id).strip(): raise ValueError("approval_id is required") + if decision not in ("approve", "deny"): + raise ValueError("decision must be 'approve' or 'deny'") endpoint = urlsplit(self.endpoint_url or self._client._base_url) approval_id = quote(str(approval_id).strip(), safe="") url = f"{endpoint.scheme}://{endpoint.netloc}/approvals/{approval_id}" @@ -152,7 +157,7 @@ async def approve(self, approval_id: str) -> Any: "POST", url, headers={**self._headers(), "Accept": "application/json"}, - json={"decision": "approve"}, + json={"decision": decision}, ) pipeline_response = await self._client._pipeline.run(request) response = pipeline_response.http_response @@ -232,6 +237,18 @@ async def call_tool( item_result = item.get("result") if isinstance(item, dict) else item return _unwrap_tool_result(item_result) + async def decide_approval(self, approval_id: str, decision: str) -> Any: + if not approval_id or not str(approval_id).strip(): + raise ValueError("approval_id is required") + if decision not in ("approve", "deny"): + raise ValueError("decision must be 'approve' or 'deny'") + approval_id = quote(str(approval_id).strip(), safe="") + return await self._request( + "POST", + f"/approvals/{approval_id}", + {"decision": decision}, + ) + class AsyncToolsOperations: """Async Action Gateway tool discovery and invocation.""" @@ -386,10 +403,14 @@ async def async_execute_tool_calls( item_result = _get(item, "result") or item status = _get(item_result, "status") if status and status != "succeeded": - results[index] = { + error_result = { "error": _get(item_result, "error") or {"message": f"tool {calls[index].name!r} failed"} } + meta = _get(item_result, "_meta") + if meta: + error_result["_meta"] = meta + results[index] = error_result else: results[index] = _get(item_result, "output") else: diff --git a/src/pydo/aio/gateway/session.py b/src/pydo/aio/gateway/session.py index 87bd9742..68dde5c1 100644 --- a/src/pydo/aio/gateway/session.py +++ b/src/pydo/aio/gateway/session.py @@ -53,6 +53,7 @@ def __init__( tools: AsyncToolsOperations, code: AsyncCodeOperations, provider: BaseProvider, + selected_tools: Optional[Sequence[str]] = None, raw: Optional[Dict[str, Any]] = None, ): self.session_urn = session_urn @@ -65,6 +66,7 @@ def __init__( self.code = code self._transport = tools._transport self.provider = provider + self.selected_tools = list(selected_tools or []) self.raw = raw or {} @property @@ -93,7 +95,11 @@ async def execute_tool_calls( async def approve(self, approval_id: str) -> Any: """Approve a pending tool invocation for this session.""" - return await self._transport.approve(approval_id) + return await self._transport.decide_approval(approval_id, "approve") + + async def deny(self, approval_id: str) -> Any: + """Deny a pending tool invocation for this session.""" + return await self._transport.decide_approval(approval_id, "deny") def __repr__(self) -> str: # pragma: no cover return f"" @@ -119,6 +125,8 @@ async def create( *, name: Optional[str] = None, permissions: Optional[Dict[str, Any]] = None, + tools: Optional[Sequence[str]] = None, + config: Optional[Dict[str, Any]] = None, ) -> AsyncSession: if not actor_id or not str(actor_id).strip(): raise ValueError("actor_id is required") @@ -130,6 +138,14 @@ async def create( "policy": policy, "actor_id": str(actor_id).strip(), } + if tools is not None: + if isinstance(tools, (str, bytes)): + raise TypeError("tools must be a sequence of tool references") + body["tools"] = list(tools) + if config is not None: + if not isinstance(config, dict): + raise TypeError("config must be a dict") + body["config"] = config raw_session = await self._post_create(body) session_urn = _pick(raw_session, "sessionUrn", "session_urn") @@ -161,6 +177,7 @@ async def create( tools=tools, code=code, provider=self._provider, + selected_tools=_pick(raw_session, "selectedTools") or [], raw=raw_session, ) @@ -195,6 +212,8 @@ async def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: mcp_url = _pick(payload, "mcpUrl", "mcp_url") if mcp_url: result["mcpUrl"] = mcp_url + if "tools" in payload: + result["selectedTools"] = payload["tools"] return result diff --git a/src/pydo/gateway/__init__.py b/src/pydo/gateway/__init__.py index b4f819d3..922c5e98 100644 --- a/src/pydo/gateway/__init__.py +++ b/src/pydo/gateway/__init__.py @@ -6,9 +6,8 @@ Session-first surface: create a session on the DigitalOcean API (``POST /v2/action-gateway/sessions``), then discover/invoke tools and run code -over the gateway REST endpoints with ``X-Session-Id``. Composio-style providers make -session tools plug into pydo inference surfaces (chat completions, messages, -responses). +through the API-returned MCP endpoint. Composio-style providers make session +tools plug into pydo inference surfaces (chat completions, messages, responses). """ from __future__ import annotations @@ -47,7 +46,6 @@ Session, SessionsOperations, normalize_permissions, - serialize_policy_json, ) from .transport import ( ACTOR_ID_HEADER, @@ -138,7 +136,6 @@ def execute_tool_calls( "Session", "SessionsOperations", "normalize_permissions", - "serialize_policy_json", "ToolsOperations", "CodeOperations", "normalize_invoke_arguments", diff --git a/src/pydo/gateway/custom_models.py b/src/pydo/gateway/custom_models.py index 1a5785db..0315d430 100644 --- a/src/pydo/gateway/custom_models.py +++ b/src/pydo/gateway/custom_models.py @@ -66,6 +66,7 @@ def __init__( recovery_hint: Optional[str] = None, invocation_id: Optional[str] = None, details: Optional[Any] = None, + meta: Optional[Dict[str, Any]] = None, ): super().__init__(message) self.message = message @@ -74,6 +75,7 @@ def __init__( self.recovery_hint = recovery_hint self.invocation_id = invocation_id self.details = details + self.meta = meta @classmethod def from_error_payload( @@ -81,6 +83,7 @@ def from_error_payload( error: Dict[str, Any], *, invocation_id: Optional[str] = None, + meta: Optional[Dict[str, Any]] = None, ) -> "GatewayToolError": return cls( error.get("message") or "tool invocation failed", @@ -89,6 +92,7 @@ def from_error_payload( recovery_hint=error.get("recovery_hint"), invocation_id=invocation_id, details=error, + meta=meta, ) diff --git a/src/pydo/gateway/custom_operations.py b/src/pydo/gateway/custom_operations.py index 04eb27e0..511eed4e 100644 --- a/src/pydo/gateway/custom_operations.py +++ b/src/pydo/gateway/custom_operations.py @@ -172,7 +172,8 @@ def list(self, *, include_all: bool = False) -> Any: By default returns the three meta-tools (``action.search``, ``action.invoke``, ``action.code``) — the intended agent workflow. - Pass ``include_all=True`` for the full concrete tool catalog. + Pass ``include_all=True`` for every tool exposed on the session MCP + endpoint, including configured ``preloadTools``. """ return self._transport.list_tools(meta=not include_all) @@ -267,7 +268,7 @@ def __call__( By default wraps the three meta-tools so the model drives the search → invoke → code workflow itself. Pass ``include_all=True``, - ``names=``, or ``search=`` to wrap concrete catalog tools instead. + ``names=``, or ``search=`` to wrap selected tools instead. """ if self._provider is None: raise RuntimeError( diff --git a/src/pydo/gateway/providers.py b/src/pydo/gateway/providers.py index 64195467..4a0f1edf 100644 --- a/src/pydo/gateway/providers.py +++ b/src/pydo/gateway/providers.py @@ -319,10 +319,14 @@ def execute_tool_calls( item_result = _get(item, "result") or item status = _get(item_result, "status") if status and status != "succeeded": - results[index] = { + error_result = { "error": _get(item_result, "error") or {"message": f"tool {calls[index].name!r} failed"} } + meta = _get(item_result, "_meta") + if meta: + error_result["_meta"] = meta + results[index] = error_result else: results[index] = _get(item_result, "output") else: @@ -333,7 +337,7 @@ def execute_tool_calls( def _error_payload(exc: Any) -> Dict[str, Any]: - return { + payload = { "error": { "message": str(exc), "class": getattr(exc, "error_class", None), @@ -341,6 +345,10 @@ def _error_payload(exc: Any) -> Dict[str, Any]: "recovery_hint": getattr(exc, "recovery_hint", None), } } + meta = getattr(exc, "meta", None) + if isinstance(meta, dict) and meta: + payload["_meta"] = meta + return payload __all__ = [ diff --git a/src/pydo/gateway/session.py b/src/pydo/gateway/session.py index d09d10f4..51243185 100644 --- a/src/pydo/gateway/session.py +++ b/src/pydo/gateway/session.py @@ -7,7 +7,6 @@ from __future__ import annotations -import json as _json import uuid from typing import Any, Dict, List, Optional, Sequence @@ -26,7 +25,7 @@ ) _SESSIONS_PATH = "/v2/action-gateway/sessions" -_DEFAULT_POLICY: Dict[str, Any] = {"defaultAction": "allow"} +_DEFAULT_POLICY: Dict[str, Any] = {"defaultAction": "ask"} def _pick(data: Dict[str, Any], *keys: str) -> Any: @@ -40,7 +39,7 @@ def normalize_permissions(permissions: Optional[Dict[str, Any]]) -> Dict[str, An """Normalize SDK permissions into the wire policy object. Accepts snake_case ``default_action`` or wire ``defaultAction``. When - omitted, returns ``{"defaultAction": "allow"}``. + omitted, returns ``{"defaultAction": "ask"}``. """ if permissions is None: return dict(_DEFAULT_POLICY) @@ -48,7 +47,7 @@ def normalize_permissions(permissions: Optional[Dict[str, Any]]) -> Dict[str, An default_action = ( permissions.get("default_action") if "default_action" in permissions - else permissions.get("defaultAction", "allow") + else permissions.get("defaultAction", "ask") ) rules_in = permissions.get("rules") or [] rules: List[Dict[str, Any]] = [] @@ -71,16 +70,12 @@ def normalize_permissions(permissions: Optional[Dict[str, Any]]) -> Dict[str, An return {"defaultAction": default_action, "rules": rules} -def serialize_policy_json(permissions: Optional[Dict[str, Any]]) -> str: - return _json.dumps(normalize_permissions(permissions), separators=(",", ":")) - - class Session: """A gateway session bound to an ``actor_id`` and tool policy. Create via :meth:`SessionsOperations.create`. Use ``url`` for external MCP clients, ``tools()`` for inference ``tools=``, and - ``handle_tool_calls`` to execute model tool calls over REST. + ``handle_tool_calls`` to execute model tool calls over MCP. """ def __init__( @@ -94,6 +89,7 @@ def __init__( tools: ToolsOperations, code: CodeOperations, provider: BaseProvider, + selected_tools: Optional[Sequence[str]] = None, raw: Optional[Dict[str, Any]] = None, ): self.session_urn = session_urn @@ -106,6 +102,7 @@ def __init__( self.code = code self._transport = tools._transport self.provider = provider + self.selected_tools = list(selected_tools or []) self.raw = raw or {} @property @@ -137,7 +134,11 @@ def execute_tool_calls( def approve(self, approval_id: str) -> Any: """Approve a pending tool invocation for this session.""" - return self._transport.approve(approval_id) + return self._transport.decide_approval(approval_id, "approve") + + def deny(self, approval_id: str) -> Any: + """Deny a pending tool invocation for this session.""" + return self._transport.decide_approval(approval_id, "deny") def __repr__(self) -> str: # pragma: no cover - debug aid return f"" @@ -163,13 +164,19 @@ def create( *, name: Optional[str] = None, permissions: Optional[Dict[str, Any]] = None, + tools: Optional[Sequence[str]] = None, + config: Optional[Dict[str, Any]] = None, ) -> Session: """Create a session. :param actor_id: Required actor identifier used to evaluate the policy. :param name: Optional display name (auto-generated when omitted). :param permissions: Optional policy. When omitted, defaults to - ``{"defaultAction": "allow"}``. + ``{"defaultAction": "ask"}``. + :param tools: Optional tool or version-pinned toolbelt references. + Omit for all tools; pass an empty sequence for no tools. + :param config: Optional session configuration, including + ``preloadTools``. """ if not actor_id or not str(actor_id).strip(): raise ValueError("actor_id is required") @@ -181,6 +188,14 @@ def create( "policy": policy, "actor_id": str(actor_id).strip(), } + if tools is not None: + if isinstance(tools, (str, bytes)): + raise TypeError("tools must be a sequence of tool references") + body["tools"] = list(tools) + if config is not None: + if not isinstance(config, dict): + raise TypeError("config must be a dict") + body["config"] = config raw_session = self._post_create(body) session_urn = _pick(raw_session, "sessionUrn", "session_urn") @@ -212,6 +227,7 @@ def create( tools=tools, code=code, provider=self._provider, + selected_tools=_pick(raw_session, "selectedTools") or [], raw=raw_session, ) @@ -229,7 +245,9 @@ def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: request.url = client.format_url(request.url) pipeline_response = client._pipeline.run(request) response = pipeline_response.http_response - response_body = response.text() if hasattr(response, "text") else response.body() + response_body = ( + response.text() if hasattr(response, "text") else response.body() + ) if response.status_code not in (200, 201): _raise_gateway_http_error(response) payload = _parse_json_body(response_body) @@ -246,6 +264,8 @@ def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: mcp_url = _pick(payload, "mcpUrl", "mcp_url") if mcp_url: result["mcpUrl"] = mcp_url + if "tools" in payload: + result["selectedTools"] = payload["tools"] return result @@ -253,5 +273,4 @@ def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: "Session", "SessionsOperations", "normalize_permissions", - "serialize_policy_json", ] diff --git a/src/pydo/gateway/transport.py b/src/pydo/gateway/transport.py index 0b644ae0..f8937c76 100644 --- a/src/pydo/gateway/transport.py +++ b/src/pydo/gateway/transport.py @@ -5,11 +5,9 @@ """Action Gateway wire layer. The public SDK surface (``ToolsOperations`` / ``CodeOperations``) only talks -to the small :class:`GatewayTransport` interface. The default transport is -REST (``/tools/search``, ``/tools/invoke``, ``/code/execute``) and requires -a session id via ``X-Session-Id`` and actor id via ``X-Actor-Id``. An -:class:`MCPTransport` remains available for callers that need JSON-RPC over -``/mcp`` / ``/mcp/meta``. +to the small :class:`GatewayTransport` interface. Sessions use MCP JSON-RPC at +the endpoint returned by the create API. REST transports remain available for +the compatibility routes and focused testing. """ from __future__ import annotations @@ -213,6 +211,7 @@ def _unwrap_call_result(result: Dict[str, Any]) -> Any: """Normalize an MCP ``tools/call`` result to its useful payload.""" if result.get("isError"): structured = result.get("structuredContent") + meta = result.get("_meta") error = None if isinstance(structured, dict): error = structured.get("error") or ( @@ -226,9 +225,11 @@ def _unwrap_call_result(result: Dict[str, Any]) -> Any: if isinstance(structured, dict) else None ), + meta=meta if isinstance(meta, dict) else None, ) raise GatewayToolError( - _content_text(result.get("content")) or "tool call failed" + _content_text(result.get("content")) or "tool call failed", + meta=meta if isinstance(meta, dict) else None, ) structured = result.get("structuredContent") @@ -351,9 +352,12 @@ def list_tools(self, *, meta: bool) -> List[Any]: def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: raise NotImplementedError - def approve(self, approval_id: str) -> Any: + def decide_approval(self, approval_id: str, decision: str) -> Any: raise NotImplementedError + def approve(self, approval_id: str) -> Any: + return self.decide_approval(approval_id, "approve") + class RESTTransport(GatewayTransport): """REST over ``/tools``, ``/tools/search``, ``/tools/invoke``, ``/code/execute``. @@ -428,6 +432,18 @@ def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: item_result = item.get("result") if isinstance(item, dict) else item return _unwrap_tool_result(item_result) + def decide_approval(self, approval_id: str, decision: str) -> Any: + if not approval_id or not str(approval_id).strip(): + raise ValueError("approval_id is required") + if decision not in ("approve", "deny"): + raise ValueError("decision must be 'approve' or 'deny'") + approval_id = quote(str(approval_id).strip(), safe="") + return self._request( + "POST", + f"/approvals/{approval_id}", + {"decision": decision}, + ) + class MCPTransport(GatewayTransport): """JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" @@ -500,9 +516,11 @@ def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: ) return _unwrap_call_result(result) - def approve(self, approval_id: str) -> Any: + def decide_approval(self, approval_id: str, decision: str) -> Any: if not approval_id or not str(approval_id).strip(): raise ValueError("approval_id is required") + if decision not in ("approve", "deny"): + raise ValueError("decision must be 'approve' or 'deny'") endpoint = urlsplit(self.endpoint_url or self._client._base_url) approval_id = quote(str(approval_id).strip(), safe="") url = f"{endpoint.scheme}://{endpoint.netloc}/approvals/{approval_id}" @@ -510,7 +528,7 @@ def approve(self, approval_id: str) -> Any: "POST", url, headers={**self._headers(), "Accept": "application/json"}, - json={"decision": "approve"}, + json={"decision": decision}, ) pipeline_response = self._client._pipeline.run(request) response = pipeline_response.http_response diff --git a/src/pydo/operations/_operations.py b/src/pydo/operations/_operations.py index 2a169aef..69091d8b 100644 --- a/src/pydo/operations/_operations.py +++ b/src/pydo/operations/_operations.py @@ -60,7 +60,7 @@ def build_toolbelts_list_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/action-gateway/toolbelts" + _url = "/v2/toolbelts" # Construct parameters if status is not None: @@ -89,7 +89,7 @@ def build_toolbelts_create_request(**kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/action-gateway/toolbelts" + _url = "/v2/toolbelts" # Construct headers if content_type is not None: @@ -110,7 +110,7 @@ def build_toolbelts_get_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/action-gateway/toolbelts/{name}" + _url = "/v2/toolbelts/{name}" path_format_arguments = { "name": _SERIALIZER.url( "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" @@ -139,7 +139,7 @@ def build_toolbelts_delete_request(name: str, **kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/action-gateway/toolbelts/{name}" + _url = "/v2/toolbelts/{name}" path_format_arguments = { "name": _SERIALIZER.url( "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" @@ -163,7 +163,7 @@ def build_toolbelts_add_tools_request(name: str, **kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/action-gateway/toolbelts/{name}/tools/add" + _url = "/v2/toolbelts/{name}/tools/add" path_format_arguments = { "name": _SERIALIZER.url( "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" @@ -191,7 +191,7 @@ def build_toolbelts_delete_tools_request(name: str, **kwargs: Any) -> HttpReques accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/action-gateway/toolbelts/{name}/tools/remove" + _url = "/v2/toolbelts/{name}/tools/remove" path_format_arguments = { "name": _SERIALIZER.url( "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index ec8305be..b9074baf 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -98,7 +98,11 @@ def jsonrpc_error(code: int, message: str, *, rpc_id: int = 1) -> dict: def call_result( - structured: Any = None, *, is_error: bool = False, text: str = "" + structured: Any = None, + *, + is_error: bool = False, + text: str = "", + meta: Any = None, ) -> dict: """MCP tools/call result shape (legacy helper for MCP-specific tests).""" result: dict = {"isError": is_error} @@ -106,6 +110,8 @@ def call_result( result["structuredContent"] = structured if text: result["content"] = [{"type": "text", "text": text}] + if meta is not None: + result["_meta"] = meta return result @@ -186,9 +192,9 @@ def session_create_response( return { "session": { "sessionUrn": session_urn, - "teamId": "42", "name": name, - "policyJson": '{"defaultAction":"allow","rules":[]}', + "actorId": "actor-123", + "policy": {"defaultAction": "ask", "rules": []}, }, "mcpUrl": f"{TEST_GATEWAY_URL}/mcp/session/test-session", "tools": [], diff --git a/tests/gateway/test_action_gateway_client.py b/tests/gateway/test_action_gateway_client.py index efe2034f..24bd6559 100644 --- a/tests/gateway/test_action_gateway_client.py +++ b/tests/gateway/test_action_gateway_client.py @@ -118,7 +118,7 @@ def run(self, request, **_kwargs): assert toolbelt.ref == "search-toolbelt@1" request = client._client._pipeline.calls[0] - assert request.url.endswith("/v2/action-gateway/toolbelts") + assert request.url.endswith("/v2/toolbelts") assert json.loads(request.content) == { "name": "search-toolbelt", "tools": ["exa_web_search"], diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index 9b435fca..d341c9cb 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -127,7 +127,12 @@ def test_session_create_uses_public_api_and_actor_header(): operations = AsyncSessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) async def scenario(): - session = await operations.create("actor-123", name="named") + session = await operations.create( + "actor-123", + name="named", + tools=["web_search@v1"], + config={"preloadTools": ["web_search@v1"]}, + ) await session.tools.list(include_all=True) return session @@ -137,13 +142,16 @@ async def scenario(): assert json.loads(create_request.content) == { "actor_id": "actor-123", "name": "named", - "policy": {"defaultAction": "allow"}, + "policy": {"defaultAction": "ask"}, + "tools": ["web_search@v1"], + "config": {"preloadTools": ["web_search@v1"]}, } tool_request = parent._client._pipeline.calls[1].request assert tool_request.url == session.url assert tool_request.headers[SESSION_ID_HEADER] == "test-session" assert tool_request.headers[ACTOR_ID_HEADER] == "actor-123" assert session.actor_id == "actor-123" + assert session.selected_tools == [] def test_session_approve_posts_to_gateway(): @@ -170,6 +178,25 @@ async def scenario(): assert session.actor_id == "actor-123" +def test_session_deny_posts_to_gateway(): + parent = make_async_parent( + [ + AsyncFakeResponse(201, session_create_response()), + AsyncFakeResponse(200, {"status": "denied"}), + ] + ) + operations = AsyncSessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + async def scenario(): + session = await operations.create("actor-123") + return await session.deny("approval-123") + + result = _run(scenario()) + request = parent._client._pipeline.calls[1].request + assert json.loads(request.content) == {"decision": "deny"} + assert result.status == "denied" + + def test_tools_callable_and_handle_tool_calls(): envelope = invoke_envelope(output={"ok": True}) gateway = make_async_gateway( diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index f8dd825b..6df94826 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -19,7 +19,6 @@ SessionsOperations, normalize_permissions, ) -from pydo.gateway.session import serialize_policy_json from pydo.gateway.transport import _META_TOOL_DEFINITIONS from .conftest import ( @@ -34,8 +33,8 @@ ) -def test_normalize_permissions_defaults_to_allow(): - assert normalize_permissions(None) == {"defaultAction": "allow"} +def test_normalize_permissions_defaults_to_ask(): + assert normalize_permissions(None) == {"defaultAction": "ask"} def test_normalize_permissions_accepts_snake_case(): @@ -67,10 +66,6 @@ def test_normalize_permissions_rejects_legacy_toolbelt_key(): normalize_permissions({"rules": [{"toolbelt": "read-only@1.2.3"}]}) -def test_serialize_policy_json(): - assert json.loads(serialize_policy_json(None))["defaultAction"] == "allow" - - def test_sessions_create_requires_actor_id(): ops = SessionsOperations(make_parent([]), gateway_endpoint=TEST_GATEWAY_URL) with pytest.raises(ValueError, match="actor_id"): @@ -107,7 +102,7 @@ def test_sessions_create_posts_to_do_api_and_binds_returned_mcp_url(): body = json.loads(create_req.content) assert body["actor_id"] == "user-123" assert "end_user_id" not in body - assert body["policy"] == {"defaultAction": "allow"} + assert body["policy"] == {"defaultAction": "ask"} assert body["name"].startswith("pydo-session-") assert session.session_urn == TEST_SESSION_URN @@ -153,6 +148,20 @@ def test_sessions_create_with_permissions_and_name(): assert session.name == "named" +def test_sessions_create_sends_tool_selection_and_config(): + parent = make_parent([FakeResponse(200, session_create_response())]) + session = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL).create( + "u1", + tools=["web_search@v1", "toolbelt:read-only@2"], + config={"preloadTools": ["web_search@v1"]}, + ) + + body = json.loads(parent._client._pipeline.calls[0].request.content) + assert body["tools"] == ["web_search@v1", "toolbelt:read-only@2"] + assert body["config"] == {"preloadTools": ["web_search@v1"]} + assert session.selected_tools == [] + + def test_session_approve_posts_to_gateway(): parent = make_parent( [ @@ -172,3 +181,58 @@ def test_session_approve_posts_to_gateway(): assert request.headers[ACTOR_ID_HEADER] == "user-123" assert json.loads(request.content) == {"decision": "approve"} assert result.status == "approved" + + +def test_session_deny_posts_to_gateway(): + parent = make_parent( + [ + FakeResponse(200, session_create_response()), + FakeResponse(200, {"status": "denied"}), + ] + ) + session = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL).create( + "user-123" + ) + + result = session.deny("approval-123") + + request = parent._client._pipeline.calls[1].request + assert json.loads(request.content) == {"decision": "deny"} + assert result.status == "denied" + + +def test_handle_tool_calls_preserves_approval_metadata(): + parent = make_parent( + [ + FakeResponse(200, session_create_response()), + FakeResponse( + 200, + jsonrpc_result( + call_result( + structured={ + "results": [ + { + "tool": "exa_web_search", + "result": { + "status": "failed", + "error": {"message": "approval required"}, + "_meta": { + "status": "requires_approval", + "approval_id": "approval-123", + }, + }, + } + ] + }, + ) + ), + ), + ] + ) + session = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL).create( + "user-123" + ) + + messages = session.handle_tool_calls(chat_tool_response(name="exa_web_search")) + content = json.loads(messages[0]["content"]) + assert content["_meta"]["approval_id"] == "approval-123" From 5efe1e2108621e4d2f93cff0d614fecb72536306 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 29 Jul 2026 13:35:59 -0500 Subject: [PATCH 13/19] remove doc --- docs/gateway-sdk-experience.md | 246 --------------------------- examples/gateway/session_controls.py | 41 +++++ 2 files changed, 41 insertions(+), 246 deletions(-) delete mode 100644 docs/gateway-sdk-experience.md create mode 100644 examples/gateway/session_controls.py diff --git a/docs/gateway-sdk-experience.md b/docs/gateway-sdk-experience.md deleted file mode 100644 index d2429268..00000000 --- a/docs/gateway-sdk-experience.md +++ /dev/null @@ -1,246 +0,0 @@ -# Action Gateway — Python SDK Experience - -**Audience:** internal alignment on the developer experience of the Action Gateway surface in `pydo`. -**Status:** proposal / preview. Feedback welcome — nothing here is final. - -The Action Gateway gives models access to a large catalog of third-party tools plus a sandboxed Python runtime. Usage is **session-first**: create a session on the DigitalOcean API, then call tools through the returned MCP URL. - ---- - -## 1. Setup - -```bash -pip install pydo -export DIGITALOCEAN_TOKEN=... -``` - -```python -from pydo.action_gateway import ActionGatewayClient - -client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) - -session = client.session.create( - actor_id="user-123", # required - # permissions optional — defaults to ask -) -``` - -`actor_id` is required. If you omit `permissions`, the SDK creates a default policy of `{"defaultAction": "ask"}`. Optional permissions: - -```python -session = client.session.create( - actor_id="user-123", - permissions={ - "default_action": "ask", - "rules": [ - {"tool": "toolbelt:read-only@1", "action": "allow"}, - {"tool": "gmail", "action": "allow"}, - ], - }, -) -``` - -Session create hits `POST /v2/action-gateway/sessions` on `api.digitalocean.com` with `name`, `policy`, and `actor_id`. It can also send `tools` (omitted means all, an empty list means none) and opaque `config`, including `preloadTools`. Tool calls use the `mcpUrl` returned by the API. The same actor is sent as `X-Actor-Id` on gateway requests. - -```python -session = client.session.create( - actor_id="user-123", - tools=["exa_web_search@v1"], - config={"preloadTools": ["exa_web_search@v1"]}, - permissions={ - "default_action": "ask", - "rules": [{"tool": "exa_web_search", "action": "allow"}], - }, -) -``` - -`session.url` is the session-pinned MCP URL for external MCP clients: - -```text -https://actions.do-ai.run/mcp/session/ -``` - ---- - -## 2. Basic usage (no model involved) - -```python -results = session.tools.search("search the web for recent news") -session_tools = session.tools.list(include_all=True) - -output = session.tools.invoke_one( - "exa_web_search", - {"query": "DigitalOcean news", "max_results": 5}, -) - -envelope = session.tools.invoke([ - {"tool": "exa_web_search", "arguments": {"query": "DigitalOcean news"}}, - {"tool": "exa_web_fetch", "arguments": {"url": "https://www.digitalocean.com"}}, -]) - -result = session.code.execute("print(sum(range(10)))") -``` - -These calls use JSON-RPC over the `mcpUrl` returned by session creation, with `X-Session-Id` and `X-Actor-Id`. - -For policies that require approval, approve a pending invocation by ID: - -```python -session.approve(approval_id) -``` - -See `examples/gateway/approval_flow.py` for a complete request, approval, and retry flow. - ---- - -## 3. Using tools with a model - -- **`session.tools()`** — provider-formatted tool definitions for `tools=` -- **`session.handle_tool_calls(response)`** — execute the model's tool calls and return ready-to-append messages - -### Chat Completions - -```python -from pydo.action_gateway import ActionGatewayClient - -client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) -session = client.session.create(actor_id="user-123") - -tools = session.tools() -messages = [{"role": "user", "content": - "Find the latest news about DigitalOcean and summarize it."}] - -while True: - response = client.chat.completions.create( - model="openai-gpt-4o", - messages=messages, - tools=tools, - ) - message = response.choices[0].message - if not message.get("tool_calls"): - break - - messages.append(dict(message)) - messages.extend(session.handle_tool_calls(response)) - -print(message["content"]) -``` - -### Messages API - -```python -from pydo.action_gateway import ActionGatewayClient, MessagesProvider - -client = ActionGatewayClient( - token=os.environ["DIGITALOCEAN_TOKEN"], - gateway_provider=MessagesProvider(), -) -session = client.session.create(actor_id="user-123") - -tools = session.tools() -# ... same loop with client.messages.create and session.handle_tool_calls -``` - ---- - -## 4. Meta-tools vs. concrete tools - -`session.tools()` defaults to the three meta-tools (`action_search`, `action_invoke`, `action_code`). With `config.preloadTools`, request every tool exposed on this session MCP endpoint or select by name: - -```python -tools = session.tools(include_all=True) -tools = session.tools(names=["exa_web_search"]) -tools = session.tools(search="post a message to slack", limit=5) -``` - ---- - -## 5. Toolbelts and policies - -Create a versioned toolbelt from provider-qualified tool names: - -```python -toolbelt = client.create_toolbelt( - name="search-toolbelt", - tools=["exa_web_search", "exa_web_fetch"], -) -print(toolbelt.ref) # search-toolbelt@1 -``` - -Toolbelts are public DigitalOcean API resources, so the base CRUD surface is -generated from the public OpenAPI specification under `client.toolbelts`: - -```python -client.toolbelts.list(status="active") -client.toolbelts.get("search-toolbelt", version="1") -client.toolbelts.add_tools("search-toolbelt", {"tools": ["jira_create_issue"]}) -client.toolbelts.delete_tools("search-toolbelt", {"tools": ["exa_web_fetch"]}) -client.toolbelts.delete("search-toolbelt") -``` - -`client.create_toolbelt(...)` is the Action Gateway convenience wrapper around -the generated `client.toolbelts.create(body=...)` operation. - -Pin that version in a session policy: - -```python -session = client.session.create( - actor_id="user-123", - permissions={ - "default_action": "ask", - "rules": [ - {"tool": f"toolbelt:{toolbelt.ref}", "action": "allow"}, - ], - }, -) -``` - -Toolbelt creation maps to `POST /v2/toolbelts`. The response exposes both `toolbelt.reference` and the shorter `toolbelt.ref` alias. - ---- - -## 6. Responses API - -```python -from pydo.action_gateway import ActionGatewayClient, ResponsesProvider - -client = ActionGatewayClient( - token=os.environ["DIGITALOCEAN_TOKEN"], - gateway_provider=ResponsesProvider(), -) -session = client.session.create(actor_id="user-123") -response = client.responses.create( - model="openai-gpt-4o", - input="What DigitalOcean Droplet sizes are available in NYC3?", - tools=session.tools(), -) -tool_outputs = session.handle_tool_calls(response) -``` - -The Responses API can also connect to `session.url` directly when its MCP tool -surface supports remote MCP servers. Gateway policy approval remains separate -from model-provider approval: use `session.approve(approval_id)` or -`session.deny(approval_id)`, then retry the tool call. - ---- - -## 7. Async - -```python -from pydo.action_gateway.aio import ActionGatewayClient - -async with ActionGatewayClient(token=token) as client: - session = await client.session.create(actor_id="user-123") - tools = await session.tools() - response = await client.chat.completions.create(..., tools=tools) - messages.extend(await session.handle_tool_calls(response)) -``` - ---- - -## 8. Design notes - -- **Session-first.** Bare gateway calls without a session are unsupported. -- **MCP for SDK execution.** `session.url` is the same returned MCP endpoint used by the SDK. -- **Provider pattern.** Chat Completions / Messages / Responses formatting stays in small provider classes. -- **Same DO token** for session create and the returned MCP endpoint. diff --git a/examples/gateway/session_controls.py b/examples/gateway/session_controls.py new file mode 100644 index 00000000..ea110b6d --- /dev/null +++ b/examples/gateway/session_controls.py @@ -0,0 +1,41 @@ +"""Control Action Gateway discovery, direct tools, and invocation policy. + +The three session controls have separate roles: + tools catalog available to action_search/action_invoke + config.preloadTools concrete tools also exposed directly over MCP + permissions allow, ask, or deny each invocation + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + tools=["exa_web_search@v1", "exa_web_fetch@v1"], + config={"preloadTools": ["exa_web_search@v1"]}, + permissions={ + "default_action": "deny", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "exa_web_fetch", "action": "ask"}, + ], + }, +) + +print("MCP URL:", session.url) +print("Selected for search/invoke:", session.selected_tools) +print( + "Exposed directly:", + [tool.name for tool in session.tools.list(include_all=True)], +) + +results = session.tools.search("search or fetch a public web page") +print("Search results:", results) From 72bd5d0fbca4bbc1dfc573c8df694e165e63eb0d Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 29 Jul 2026 14:39:43 -0500 Subject: [PATCH 14/19] Generate Action Gateway public API clients --- examples/gateway/create_toolbelt.py | 12 + examples/gateway/public_api.py | 20 + src/pydo/_client.py | 24 + src/pydo/action_gateway/__init__.py | 5 + src/pydo/action_gateway/aio/__init__.py | 5 + src/pydo/aio/_client.py | 24 + src/pydo/aio/gateway/session.py | 27 +- src/pydo/aio/operations/__init__.py | 8 + src/pydo/aio/operations/_operations.py | 4201 +++++++++++++++-- src/pydo/gateway/__init__.py | 2 +- src/pydo/gateway/session.py | 28 +- src/pydo/gateway/transport.py | 7 - src/pydo/operations/__init__.py | 8 + src/pydo/operations/_operations.py | 4721 +++++++++++++++++-- tests/gateway/conftest.py | 16 + tests/gateway/test_action_gateway_client.py | 14 +- tests/gateway/test_async_gateway.py | 8 +- tests/gateway/test_session.py | 10 +- 18 files changed, 8283 insertions(+), 857 deletions(-) create mode 100644 examples/gateway/public_api.py diff --git a/examples/gateway/create_toolbelt.py b/examples/gateway/create_toolbelt.py index bca6edb7..3a30a24c 100644 --- a/examples/gateway/create_toolbelt.py +++ b/examples/gateway/create_toolbelt.py @@ -19,3 +19,15 @@ ) print(toolbelt.ref) + +# Public Tool Registry APIs are generated from DigitalOcean's OpenAPI spec. +print(client.toolbelts.list(status="active")) +print(client.toolbelts.get("search-toolbelt", version="1")) +client.toolbelts.add_tools( + "search-toolbelt", + body={"tools": ["jira_create_issue"]}, +) +client.toolbelts.delete_tools( + "search-toolbelt", + body={"tools": ["exa_web_fetch"]}, +) diff --git a/examples/gateway/public_api.py b/examples/gateway/public_api.py new file mode 100644 index 00000000..be5405f0 --- /dev/null +++ b/examples/gateway/public_api.py @@ -0,0 +1,20 @@ +"""Use the OpenAPI-generated Action Gateway control-plane APIs. + +Required env: + DIGITALOCEAN_TOKEN +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) + +print(client.tools.list()) +print(client.tools.list_toolkits()) +print(client.tools.list_providers()) +print(client.tools.get_definition("exa_web_search", version="v1")) + +print(client.connections.list(user_id="example-user")) +print(client.users.list()) +print(client.sessions_api.list(end_user_id="example-user")) diff --git a/src/pydo/_client.py b/src/pydo/_client.py index 7a91eb73..da85c809 100644 --- a/src/pydo/_client.py +++ b/src/pydo/_client.py @@ -26,6 +26,7 @@ ByoipPrefixesOperations, CdnOperations, CertificatesOperations, + ConnectionsOperations, DatabasesOperations, DedicatedInferencesOperations, DomainsOperations, @@ -54,13 +55,16 @@ ReservedIPv6ActionsOperations, ReservedIPv6Operations, SecurityOperations, + SessionsOperations, SizesOperations, SnapshotsOperations, SpacesKeyOperations, SshKeysOperations, TagsOperations, ToolbeltsOperations, + ToolsOperations, UptimeOperations, + UsersOperations, VectorDatabasesOperations, VolumeActionsOperations, VolumeSnapshotsOperations, @@ -78,8 +82,16 @@ class GeneratedClient: # pylint: disable=client-accepts-api-version-keyword,too-many-instance-attributes """GeneratedClient. + :ivar tools: ToolsOperations operations + :vartype tools: pydo.operations.ToolsOperations :ivar toolbelts: ToolbeltsOperations operations :vartype toolbelts: pydo.operations.ToolbeltsOperations + :ivar connections: ConnectionsOperations operations + :vartype connections: pydo.operations.ConnectionsOperations + :ivar users: UsersOperations operations + :vartype users: pydo.operations.UsersOperations + :ivar sessions: SessionsOperations operations + :vartype sessions: pydo.operations.SessionsOperations :ivar one_clicks: OneClicksOperations operations :vartype one_clicks: pydo.operations.OneClicksOperations :ivar account: AccountOperations operations @@ -226,9 +238,21 @@ def __init__( self._serialize = Serializer() self._deserialize = Deserializer() self._serialize.client_side_validation = False + self.tools = ToolsOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.toolbelts = ToolbeltsOperations( self._client, self._config, self._serialize, self._deserialize ) + self.connections = ConnectionsOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.users = UsersOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.sessions = SessionsOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.one_clicks = OneClicksOperations( self._client, self._config, self._serialize, self._deserialize ) diff --git a/src/pydo/action_gateway/__init__.py b/src/pydo/action_gateway/__init__.py index 5803e878..70a8e6c4 100644 --- a/src/pydo/action_gateway/__init__.py +++ b/src/pydo/action_gateway/__init__.py @@ -60,7 +60,11 @@ "responses", "session", "sessions", + "sessions_api", + "connections", + "tools", "toolbelts", + "users", ) @@ -104,6 +108,7 @@ def __init__( "Action Gateway package is unavailable; " "ensure pydo.gateway is installed" ) + self.sessions_api = self.sessions self.sessions = gateway.sessions self.session = self.sessions self.provider = gateway.provider diff --git a/src/pydo/action_gateway/aio/__init__.py b/src/pydo/action_gateway/aio/__init__.py index 86b3151a..3115f095 100644 --- a/src/pydo/action_gateway/aio/__init__.py +++ b/src/pydo/action_gateway/aio/__init__.py @@ -45,7 +45,11 @@ "responses", "session", "sessions", + "sessions_api", + "connections", + "tools", "toolbelts", + "users", ) @@ -82,6 +86,7 @@ def __init__( "Action Gateway package is unavailable; " "ensure pydo.aio.gateway is installed" ) + self.sessions_api = self.sessions self.sessions = gateway.sessions self.session = self.sessions self.provider = gateway.provider diff --git a/src/pydo/aio/_client.py b/src/pydo/aio/_client.py index c8769da0..04057e72 100644 --- a/src/pydo/aio/_client.py +++ b/src/pydo/aio/_client.py @@ -26,6 +26,7 @@ ByoipPrefixesOperations, CdnOperations, CertificatesOperations, + ConnectionsOperations, DatabasesOperations, DedicatedInferencesOperations, DomainsOperations, @@ -54,13 +55,16 @@ ReservedIPv6ActionsOperations, ReservedIPv6Operations, SecurityOperations, + SessionsOperations, SizesOperations, SnapshotsOperations, SpacesKeyOperations, SshKeysOperations, TagsOperations, ToolbeltsOperations, + ToolsOperations, UptimeOperations, + UsersOperations, VectorDatabasesOperations, VolumeActionsOperations, VolumeSnapshotsOperations, @@ -78,8 +82,16 @@ class GeneratedClient: # pylint: disable=client-accepts-api-version-keyword,too-many-instance-attributes """GeneratedClient. + :ivar tools: ToolsOperations operations + :vartype tools: pydo.aio.operations.ToolsOperations :ivar toolbelts: ToolbeltsOperations operations :vartype toolbelts: pydo.aio.operations.ToolbeltsOperations + :ivar connections: ConnectionsOperations operations + :vartype connections: pydo.aio.operations.ConnectionsOperations + :ivar users: UsersOperations operations + :vartype users: pydo.aio.operations.UsersOperations + :ivar sessions: SessionsOperations operations + :vartype sessions: pydo.aio.operations.SessionsOperations :ivar one_clicks: OneClicksOperations operations :vartype one_clicks: pydo.aio.operations.OneClicksOperations :ivar account: AccountOperations operations @@ -226,9 +238,21 @@ def __init__( self._serialize = Serializer() self._deserialize = Deserializer() self._serialize.client_side_validation = False + self.tools = ToolsOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.toolbelts = ToolbeltsOperations( self._client, self._config, self._serialize, self._deserialize ) + self.connections = ConnectionsOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.users = UsersOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.sessions = SessionsOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.one_clicks = OneClicksOperations( self._client, self._config, self._serialize, self._deserialize ) diff --git a/src/pydo/aio/gateway/session.py b/src/pydo/aio/gateway/session.py index 68dde5c1..5c940d76 100644 --- a/src/pydo/aio/gateway/session.py +++ b/src/pydo/aio/gateway/session.py @@ -10,15 +10,11 @@ import uuid from typing import Any, Dict, List, Optional, Sequence -from azure.core.rest import HttpRequest - from pydo.custom_extensions import _BaseURLProxy from pydo.gateway.custom_models import GatewayProtocolError from pydo.gateway.providers import BaseProvider, default_provider from pydo.gateway.session import normalize_permissions from pydo.gateway.transport import ( - _parse_json_body, - _raise_gateway_http_error, resolve_gateway_base_url, ) @@ -29,8 +25,6 @@ async_execute_tool_calls, ) -_SESSIONS_PATH = "/v2/action-gateway/sessions" - def _pick(data: Dict[str, Any], *keys: str) -> Any: for key in keys: @@ -106,7 +100,7 @@ def __repr__(self) -> str: # pragma: no cover class AsyncSessionsOperations: - """Async create via ``POST /v2/action-gateway/sessions`` on the DO API.""" + """Create sessions through the generated async ``/v2/sessions`` operation.""" def __init__( self, @@ -116,6 +110,7 @@ def __init__( provider: Optional[BaseProvider] = None, ): self._parent = parent_client + self._sessions_api = parent_client.sessions self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) self._provider = provider or default_provider() @@ -182,23 +177,7 @@ async def create( ) async def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: - client = self._parent._client - request = HttpRequest( - "POST", - _SESSIONS_PATH, - headers={ - "Content-Type": "application/json", - "Accept": "application/json", - }, - json=body, - ) - request.url = client.format_url(request.url) - pipeline_response = await client._pipeline.run(request) - response = pipeline_response.http_response - body_bytes = await response.read() - if response.status_code not in (200, 201): - _raise_gateway_http_error(response) - payload = _parse_json_body(body_bytes) + payload = await self._sessions_api.create(body=body) if not isinstance(payload, dict): raise GatewayProtocolError( f"unexpected session create response: {payload!r}" diff --git a/src/pydo/aio/operations/__init__.py b/src/pydo/aio/operations/__init__.py index 574a164f..3960eadb 100644 --- a/src/pydo/aio/operations/__init__.py +++ b/src/pydo/aio/operations/__init__.py @@ -4,7 +4,11 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- +from ._operations import ToolsOperations from ._operations import ToolbeltsOperations +from ._operations import ConnectionsOperations +from ._operations import UsersOperations +from ._operations import SessionsOperations from ._operations import OneClicksOperations from ._operations import AccountOperations from ._operations import SshKeysOperations @@ -64,7 +68,11 @@ from ._patch import patch_sdk as _patch_sdk __all__ = [ + "ToolsOperations", "ToolbeltsOperations", + "ConnectionsOperations", + "UsersOperations", + "SessionsOperations", "OneClicksOperations", "AccountOperations", "SshKeysOperations", diff --git a/src/pydo/aio/operations/_operations.py b/src/pydo/aio/operations/_operations.py index 278dba52..c9c03aff 100644 --- a/src/pydo/aio/operations/_operations.py +++ b/src/pydo/aio/operations/_operations.py @@ -113,6 +113,11 @@ build_certificates_delete_request, build_certificates_get_request, build_certificates_list_request, + build_connections_create_request, + build_connections_delete_request, + build_connections_get_request, + build_connections_list_request, + build_connections_update_request, build_databases_add_connection_pool_request, build_databases_add_request, build_databases_add_user_request, @@ -617,6 +622,9 @@ build_security_post_restore_secret_request, build_security_update_secret_request, build_security_update_settings_plan_request, + build_sessions_create_request, + build_sessions_delete_request, + build_sessions_list_request, build_sizes_list_request, build_snapshots_delete_request, build_snapshots_get_request, @@ -644,6 +652,10 @@ build_toolbelts_delete_tools_request, build_toolbelts_get_request, build_toolbelts_list_request, + build_tools_get_definition_request, + build_tools_list_providers_request, + build_tools_list_request, + build_tools_list_toolkits_request, build_uptime_create_alert_request, build_uptime_create_check_request, build_uptime_delete_alert_request, @@ -655,6 +667,8 @@ build_uptime_list_checks_request, build_uptime_update_alert_request, build_uptime_update_check_request, + build_users_get_request, + build_users_list_request, build_vector_databases_create_request, build_vector_databases_delete_request, build_vector_databases_get_credentials_request, @@ -712,6 +726,976 @@ ] +class ToolsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`tools` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list( + self, + *, + toolkit_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """List Tools. + + Lists active Action Gateway tools visible to the authenticated team. + + :keyword toolkit_id: Filter tools by toolkit identifier. Default value is None. + :paramtype toolkit_id: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "definitions": [ + { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "auth": { + "baseUrlResolution": { + "httpLookup": { + "baseUrlTemplate": "str", # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "caseInsensitive": bool, # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "extractField": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "matchField": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "matchValue": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "match_value_parameter": "str", # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "method": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "requiredScopes": [ + "str" # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access + token), selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When + both are empty, exactly one entry whose own "scopes" + array contains required_scopes must exist. Configuring + only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + ], + "trimTrailingSlash": bool, # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "url": "str" # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + } + }, + "credentialBinding": "str", # Optional. + "credentialRefSource": "str", # Optional. + "doManagedCredentialRef": "str", # Optional. + "injection": { + "location": "str", # Optional. + "name": "str", # Optional. + "scheme": "str" # Optional. + }, + "modes": [ + "str" # Optional. + ], + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + }, + "classification": { + "dataClasses": [ + "str" # Optional. + ], + "operation": "str", # Optional. + "risk": "str" # Optional. + }, + "description": "str", # Optional. + "execution": { + "adapterVersion": "str", # Optional. + "configRef": "str", # Optional. + "http": { + "allowedHosts": [ + "str" # Optional. + ], + "baseUrl": "str", # Optional. + "method": "str", # Optional. + "path": "str", # Optional. + "requestEncoding": "str", # Optional. + "responseFormat": "str" # Optional. + }, + "mcp": { + "allowedHosts": [ + "str" # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote + MCP server (as opposed to a plain HTTP endpoint). endpoint is + the remote MCP server's URL, tool_name is the name the remote + server expects on tools/call (may differ from this tool's + registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and + server_ref is an opaque label identifying the remote server + for logging/metrics/allowlisting. + ], + "endpoint": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "serverRef": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "toolName": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "transport": "str" # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + }, + "type": "str" # Optional. + }, + "flipperName": "str", # Optional. + "hooks": { + "usage": { + "billable": bool, # Optional. When usage + metadata is present, false prevents billing. Omitting usage + metadata leaves consumers' legacy billing classification + unchanged. + "meters": [ + { + "quantitySource": "str", # + Optional. + "sku": "str", # Optional. + "unit": "str" # Optional. + } + ] + } + }, + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "policy": { + "permission": "str" # Optional. + }, + "reliability": { + "maxOutputBytes": "str", # Optional. + "retry": { + "backoff": "str", # Optional. + "maxAttempts": 0, # Optional. + "retryOn": [ + "str" # Optional. + ] + }, + "timeoutMs": 0 # Optional. + }, + "schemaVersion": "str", # Optional. + "status": "str", # Optional. + "streamingSafe": bool, # Optional. + "tags": [ + "str" # Optional. + ], + "title": "str", # Optional. + "toolId": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the + provider-qualified, stable tool identifier + ":code:``_:code:``". Pass this value back verbatim to + the toolbelt add/remove endpoints; clients should treat it as opaque. + "toolkitId": "str", # Optional. + "transform": { + "input": {}, # Optional. Any object. + "language": "str", # Optional. + "output": {} # Optional. Any object. + }, + "version": "str" # Optional. + } + ], + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "tools": [ + { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "description": "str", # Optional. + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "streamingSafe": bool, # Optional. + "title": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the + provider-qualified, stable tool identifier + ":code:``_:code:``". Pass this value back verbatim to + the toolbelt add/remove endpoints; clients should treat it as opaque + rather than reconstructing it from toolkit_id and name. + "toolkitId": "str", # Optional. + "version": "str" # Optional. + } + ], + "version": "str" # Optional. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_request( + toolkit_id=toolkit_id, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def list_toolkits(self, **kwargs: Any) -> JSON: + """List Toolkits. + + Lists the toolkits that group Action Gateway tools. + + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolkits": [ + { + "description": "str", # Optional. + "id": "str", # Optional. + "name": "str" # Optional. + } + ], + "version": "str" # Optional. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_toolkits_request( + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def list_providers(self, **kwargs: Any) -> JSON: + """List Tool Providers. + + Lists Action Gateway providers and their connection requirements. + + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "providers": [ + { + "auth_type": "str", # Optional. + "connection_parameters": [ + { + "allowed_host_suffixes": [ + "str" # Optional. + ], + "allowed_values": [ + "str" # Optional. + ], + "description": "str", # Optional. + "input_kind": "str", # Optional. + "key": "str", # Optional. + "label": "str", # Optional. + "max_length": 0, # Optional. + "normalization": "str", # Optional. + "required": bool # Optional. + } + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "name": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_providers_request( + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def get_definition( + self, + name: str, + *, + version: Optional[str] = None, + toolkit_id: Optional[str] = None, + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Tool Definition. + + Retrieves the executable definition for an active Action Gateway tool. + + :param name: The provider-qualified tool name. Required. + :type name: str + :keyword version: The tool version. Omit to retrieve the current version. Default value is + None. + :paramtype version: str + :keyword toolkit_id: The toolkit identifier used to disambiguate a bare tool name. Default + value is None. + :paramtype toolkit_id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "auth": { + "baseUrlResolution": { + "httpLookup": { + "baseUrlTemplate": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "caseInsensitive": bool, # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "extractField": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "matchField": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "matchValue": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "match_value_parameter": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting extract_field from + that entry, and substituting it for "{value}" in base_url_template. + When match_field and match_value are both set, they select the entry. + When both are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one match field + is invalid. Resolution fails fast on zero or multiple compatible + entries. + "method": "str", # Optional. HTTPLookupSpec resolves + a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "requiredScopes": [ + "str" # Optional. HTTPLookupSpec resolves a + base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON + array, extracting extract_field from that entry, and substituting + it for "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both are + empty, exactly one entry whose own "scopes" array contains + required_scopes must exist. Configuring only one match field is + invalid. Resolution fails fast on zero or multiple compatible + entries. + ], + "trimTrailingSlash": bool, # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting extract_field from + that entry, and substituting it for "{value}" in base_url_template. + When match_field and match_value are both set, they select the entry. + When both are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one match field + is invalid. Resolution fails fast on zero or multiple compatible + entries. + "url": "str" # Optional. HTTPLookupSpec resolves a + base_url by calling url (bearer-authenticated with the just-exchanged + access token), selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for "{value}" in + base_url_template. When match_field and match_value are both set, + they select the entry. When both are empty, exactly one entry whose + own "scopes" array contains required_scopes must exist. Configuring + only one match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + } + }, + "credentialBinding": "str", # Optional. + "credentialRefSource": "str", # Optional. + "doManagedCredentialRef": "str", # Optional. + "injection": { + "location": "str", # Optional. + "name": "str", # Optional. + "scheme": "str" # Optional. + }, + "modes": [ + "str" # Optional. + ], + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + }, + "classification": { + "dataClasses": [ + "str" # Optional. + ], + "operation": "str", # Optional. + "risk": "str" # Optional. + }, + "description": "str", # Optional. + "execution": { + "adapterVersion": "str", # Optional. + "configRef": "str", # Optional. + "http": { + "allowedHosts": [ + "str" # Optional. + ], + "baseUrl": "str", # Optional. + "method": "str", # Optional. + "path": "str", # Optional. + "requestEncoding": "str", # Optional. + "responseFormat": "str" # Optional. + }, + "mcp": { + "allowedHosts": [ + "str" # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, + tool_name is the name the remote server expects on tools/call (may + differ from this tool's registry name), transport selects the wire + protocol ("streamable_http" is the only kind implemented today), and + server_ref is an opaque label identifying the remote server for + logging/metrics/allowlisting. + ], + "endpoint": "str", # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "serverRef": "str", # Optional. MCPExecution describes how + to invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "toolName": "str", # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "transport": "str" # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + }, + "type": "str" # Optional. + }, + "flipperName": "str", # Optional. + "hooks": { + "usage": { + "billable": bool, # Optional. When usage metadata is + present, false prevents billing. Omitting usage metadata leaves + consumers' legacy billing classification unchanged. + "meters": [ + { + "quantitySource": "str", # Optional. + "sku": "str", # Optional. + "unit": "str" # Optional. + } + ] + } + }, + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "policy": { + "permission": "str" # Optional. + }, + "reliability": { + "maxOutputBytes": "str", # Optional. + "retry": { + "backoff": "str", # Optional. + "maxAttempts": 0, # Optional. + "retryOn": [ + "str" # Optional. + ] + }, + "timeoutMs": 0 # Optional. + }, + "schemaVersion": "str", # Optional. + "status": "str", # Optional. + "streamingSafe": bool, # Optional. + "tags": [ + "str" # Optional. + ], + "title": "str", # Optional. + "toolId": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the provider-qualified, stable + tool identifier ":code:``_:code:``". Pass this value back + verbatim to the toolbelt add/remove endpoints; clients should treat it as opaque. + "toolkitId": "str", # Optional. + "transform": { + "input": {}, # Optional. Any object. + "language": "str", # Optional. + "output": {} # Optional. Any object. + }, + "version": "str" # Optional. + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_get_definition_request( + name=name, + version=version, + toolkit_id=toolkit_id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + class ToolbeltsOperations: """ .. warning:: @@ -1274,13 +2258,34 @@ async def delete(self, name: str, **kwargs: Any) -> JSON: :param name: The natural key identifying the toolbelt. Required. :type name: str - :return: JSON or JSON object + :return: JSON object :rtype: JSON :raises ~azure.core.exceptions.HttpResponseError: Example: .. code-block:: python + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } # response body for status code(s): 404 response == { "id": "str", # A short identifier corresponding to the HTTP status code @@ -1649,207 +2654,2597 @@ async def add_tools( else: deserialized = None - if response.status_code == 404: - response_headers["ratelimit-limit"] = self._deserialize( - "int", response.headers.get("ratelimit-limit") - ) - response_headers["ratelimit-remaining"] = self._deserialize( - "int", response.headers.get("ratelimit-remaining") - ) - response_headers["ratelimit-reset"] = self._deserialize( - "int", response.headers.get("ratelimit-reset") - ) - - if response.content: - deserialized = response.json() - else: - deserialized = None - + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def delete_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def delete_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def delete_tools( + self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_delete_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class ConnectionsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`connections` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list( + self, + *, + provider: Optional[str] = None, + user_id: Optional[str] = None, + status: Optional[str] = None, + sort: Optional[str] = None, + sort_direction: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any + ) -> JSON: + """List Connections. + + Lists OAuth connections owned by the authenticated team. + + :keyword provider: Filter by provider name. Default value is None. + :paramtype provider: str + :keyword user_id: Filter by end-user identifier. Default value is None. + :paramtype user_id: str + :keyword status: Filter by connection status. Default value is None. + :paramtype status: str + :keyword sort: Field used to sort results. Default value is None. + :paramtype sort: str + :keyword sort_direction: Sort direction. Default value is None. + :paramtype sort_direction: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connections": [ + { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. + "granted_at": "2020-02-20 00:00:00", # Optional. + "id": "str", # Optional. + "provider": "str", # Optional. + "provider_display_name": "str", # Optional. + "revoked_at": "2020-02-20 00:00:00", # Optional. + "scopes": [ + "str" # Optional. + ], + "status": "str", # Optional. + "updated_at": "2020-02-20 00:00:00", # Optional. + "user_id": "str" # Optional. + } + ], + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + } + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_list_request( + provider=provider, + user_id=user_id, + status=status, + sort=sort, + sort_direction=sort_direction, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ], + "user_id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ], + "user_id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_connections_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 409]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 409: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def get(self, id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Connection. + + Retrieves an OAuth connection owned by the authenticated team. + + :param id: The connection UUID. Required. + :type id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_get_request( + id=id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def update( + self, + id: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def update( + self, + id: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def update( + self, id: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_connections_update_request( + id=id, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def delete(self, id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete a Connection. + + Revokes and deletes an OAuth connection owned by the authenticated team. + + :param id: The connection UUID. Required. + :type id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_delete_request( + id=id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class UsersOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`users` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list(self, *, page: int = 1, per_page: int = 20, **kwargs: Any) -> JSON: + """List Action Gateway Users. + + Lists end-user identifiers derived from sessions and OAuth connections for the authenticated + team. + + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "user_ids": [ + "str" # Optional. + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_users_list_request( + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def get(self, user_id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve an Action Gateway User. + + Retrieves a derived end-user view containing its sessions and OAuth connections. + + :param user_id: The end-user identifier. Required. + :type user_id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "user": { + "connections": [ + { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "granted_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "id": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "provider": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "provider_display_name": "str", # Optional. User is + a derived, team-scoped view across sessions and OAuth connections. + "revoked_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "scopes": [ + "str" # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + ], + "status": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "updated_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "user_id": "str" # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + } + ], + "sessions": [ + { + "created_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "name": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "session_urn": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "updated_at": "2020-02-20 00:00:00" # Optional. User + is a derived, team-scoped view across sessions and OAuth connections. + } + ], + "user_id": "str" # Optional. User is a derived, team-scoped view + across sessions and OAuth connections. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_users_get_request( + user_id=user_id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class SessionsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`sessions` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list( + self, + *, + end_user_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """List Action Gateway Sessions. + + Lists Action Gateway sessions owned by the authenticated team. + + :keyword end_user_id: Filter sessions by actor identifier. Default value is None. + :paramtype end_user_id: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "sessions": [ + { + "actorId": "str", # Optional. actor_id is empty when the + session is not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. + Gateway currently interprets config.preloadTools to add selected direct + tools to the session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. + "name": "str", # Optional. name is the required + human-readable session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is + "ask". SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default + value is "ask". SessionPolicyAction is the disposition + applied to a tool call. Lowercase values are canonical so + ProtoJSON matches the public REST vocabulary; the prefixed + aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. + Dictionary of :code:``. + }, + "tool": "str" # Optional. + SessionPolicySpec is the Gateway-relevant subset of a + session's permission policy. Filesystem and network policy + remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known + values are: "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted + when the request omitted tools (all tools). A present + selection with no references represents tools: []. + "version": "str" # Optional. Omitted + when the request omitted tools (all tools). A present + selection with no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_sessions_list_request( + end_user_id=end_user_id, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "actor_id": "str", # Required. + "name": "str", # Required. + "config": { + "preloadTools": [ + "str" # Optional. Concrete tools or pinned toolbelts to + expose directly beside the session meta-tools. + ] + }, + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. Known + values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary of + :code:``. + }, + "tool": "str" # Optional. Invocation policy. Omit to + use a default action of ask. + } + ] + }, + "tools": [ + "str" # Optional. Omitted enables every tool. An explicit empty + array enables no tools. Direct tools may be :code:`` or + @:code:``; toolbelt references must be version-pinned as + toolbelt::code:``@:code:``. + ] + } + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "actor_id": "str", # Required. + "name": "str", # Required. + "config": { + "preloadTools": [ + "str" # Optional. Concrete tools or pinned toolbelts to + expose directly beside the session meta-tools. + ] + }, + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. Known + values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary of + :code:``. + }, + "tool": "str" # Optional. Invocation policy. Omit to + use a default action of ask. + } + ] + }, + "tools": [ + "str" # Optional. Omitted enables every tool. An explicit empty + array enables no tools. Direct tools may be :code:`` or + @:code:``; toolbelt references must be version-pinned as + toolbelt::code:``@:code:``. + ] + } + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_sessions_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + if cls: return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore return cast(JSON, deserialized) # type: ignore - @overload - async def delete_tools( - self, - name: str, - body: JSON, - *, - content_type: str = "application/json", - **kwargs: Any - ) -> JSON: - # pylint: disable=line-too-long - """Remove Tools from a Toolbelt. - - Removes tool names and creates a new immutable toolbelt version. - - :param name: The natural key identifying the toolbelt. Required. - :type name: str - :param body: Required. - :type body: JSON - :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. - Default value is "application/json". - :paramtype content_type: str - :return: JSON object - :rtype: JSON - :raises ~azure.core.exceptions.HttpResponseError: - - Example: - .. code-block:: python - - # JSON input template you can fill out and use as your body input. - body = { - "tools": [ - "str" # Required. - ] - } - - # response body for status code(s): 200 - response == { - "toolbelt": { - "created_at": "2020-02-20 00:00:00", # Required. - "name": "str", # Required. - "reference": "str", # A reference pinned to this immutable toolbelt - version. Required. - "reference_latest": "str", # An unversioned reference to the latest - active version. Required. - "status": "str", # Required. Known values are: "active" and - "deprecated". - "tool_count": 0, # Required. - "tools": [ - "str" # Required. - ], - "updated_at": "2020-02-20 00:00:00", # Required. - "version": "str", # Required. - "description": "str", # Optional. Required. - "display_name": "str" # Optional. Required. - } - } - # response body for status code(s): 400, 404 - response == { - "id": "str", # A short identifier corresponding to the HTTP status code - returned. For example, the ID for a response returning a 404 status code would - be "not_found.". Required. - "message": "str", # A message providing additional information about the - error, including details to help resolve it when possible. Required. - "request_id": "str" # Optional. Optionally, some endpoints may include a - request ID that should be provided when reporting bugs or opening support - tickets to help identify the issue. - } - """ - - @overload - async def delete_tools( - self, - name: str, - body: IO[bytes], - *, - content_type: str = "application/json", - **kwargs: Any - ) -> JSON: - # pylint: disable=line-too-long - """Remove Tools from a Toolbelt. - - Removes tool names and creates a new immutable toolbelt version. - - :param name: The natural key identifying the toolbelt. Required. - :type name: str - :param body: Required. - :type body: IO[bytes] - :keyword content_type: Body Parameter content-type. Content type parameter for binary body. - Default value is "application/json". - :paramtype content_type: str - :return: JSON object - :rtype: JSON - :raises ~azure.core.exceptions.HttpResponseError: - - Example: - .. code-block:: python - - # response body for status code(s): 200 - response == { - "toolbelt": { - "created_at": "2020-02-20 00:00:00", # Required. - "name": "str", # Required. - "reference": "str", # A reference pinned to this immutable toolbelt - version. Required. - "reference_latest": "str", # An unversioned reference to the latest - active version. Required. - "status": "str", # Required. Known values are: "active" and - "deprecated". - "tool_count": 0, # Required. - "tools": [ - "str" # Required. - ], - "updated_at": "2020-02-20 00:00:00", # Required. - "version": "str", # Required. - "description": "str", # Optional. Required. - "display_name": "str" # Optional. Required. - } - } - # response body for status code(s): 400, 404 - response == { - "id": "str", # A short identifier corresponding to the HTTP status code - returned. For example, the ID for a response returning a 404 status code would - be "not_found.". Required. - "message": "str", # A message providing additional information about the - error, including details to help resolve it when possible. Required. - "request_id": "str" # Optional. Optionally, some endpoints may include a - request ID that should be provided when reporting bugs or opening support - tickets to help identify the issue. - } - """ - @distributed_trace_async - async def delete_tools( - self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any - ) -> JSON: + async def delete(self, session_urn: str, **kwargs: Any) -> JSON: # pylint: disable=line-too-long - """Remove Tools from a Toolbelt. + """Delete an Action Gateway Session. - Removes tool names and creates a new immutable toolbelt version. + Deletes an Action Gateway session owned by the authenticated team. - :param name: The natural key identifying the toolbelt. Required. - :type name: str - :param body: Is either a JSON type or a IO[bytes] type. Required. - :type body: JSON or IO[bytes] - :return: JSON object + :param session_urn: The URL-encoded managed agents session URN. Required. + :type session_urn: str + :return: JSON or JSON object :rtype: JSON :raises ~azure.core.exceptions.HttpResponseError: Example: .. code-block:: python - # JSON input template you can fill out and use as your body input. - body = { - "tools": [ - "str" # Required. - ] - } - - # response body for status code(s): 200 - response == { - "toolbelt": { - "created_at": "2020-02-20 00:00:00", # Required. - "name": "str", # Required. - "reference": "str", # A reference pinned to this immutable toolbelt - version. Required. - "reference_latest": "str", # An unversioned reference to the latest - active version. Required. - "status": "str", # Required. Known values are: "active" and - "deprecated". - "tool_count": 0, # Required. - "tools": [ - "str" # Required. - ], - "updated_at": "2020-02-20 00:00:00", # Required. - "version": "str", # Required. - "description": "str", # Optional. Required. - "display_name": "str" # Optional. Required. - } - } - # response body for status code(s): 400, 404 + # response body for status code(s): 404 response == { "id": "str", # A short identifier corresponding to the HTTP status code returned. For example, the ID for a response returning a 404 status code would @@ -1874,27 +5269,13 @@ async def delete_tools( } error_map.update(kwargs.pop("error_map", {}) or {}) - _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - content_type: Optional[str] = kwargs.pop( - "content_type", _headers.pop("Content-Type", None) - ) cls: ClsType[JSON] = kwargs.pop("cls", None) - content_type = content_type or "application/json" - _json = None - _content = None - if isinstance(body, (IOBase, bytes)): - _content = body - else: - _json = body - - _request = build_toolbelts_delete_tools_request( - name=name, - content_type=content_type, - json=_json, - content=_content, + _request = build_sessions_delete_request( + session_urn=session_urn, headers=_headers, params=_params, ) @@ -1909,7 +5290,7 @@ async def delete_tools( response = pipeline_response.http_response - if response.status_code not in [200, 400, 404]: + if response.status_code not in [200, 404]: if _stream: await response.read() # Load the body in memory and close the socket map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore @@ -1932,22 +5313,6 @@ async def delete_tools( else: deserialized = None - if response.status_code == 400: - response_headers["ratelimit-limit"] = self._deserialize( - "int", response.headers.get("ratelimit-limit") - ) - response_headers["ratelimit-remaining"] = self._deserialize( - "int", response.headers.get("ratelimit-remaining") - ) - response_headers["ratelimit-reset"] = self._deserialize( - "int", response.headers.get("ratelimit-reset") - ) - - if response.content: - deserialized = response.json() - else: - deserialized = None - if response.status_code == 404: response_headers["ratelimit-limit"] = self._deserialize( "int", response.headers.get("ratelimit-limit") @@ -14710,7 +18075,7 @@ async def create( }, "project_id": "str" # Optional. The ID of the project the app should be assigned to. If omitted, it will be assigned to your default project. - :code:`
`:code:`
`Requires ``project:update`` scope. + :code:`
`:code:`
`Requires ``project:assign_resource`` scope. } # response body for status code(s): 200 @@ -31433,7 +34798,7 @@ async def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: }, "project_id": "str" # Optional. The ID of the project the app should be assigned to. If omitted, it will be assigned to your default project. - :code:`
`:code:`
`Requires ``project:update`` scope. + :code:`
`:code:`
`Requires ``project:assign_resource`` scope. } # response body for status code(s): 200 @@ -112714,8 +116079,10 @@ async def list_clusters( } ], "pg_allow_replication": bool # - Optional. For Postgres clusters, set to ``true`` for a user - with replication rights. This option is not currently + Optional. For PostgreSQL clusters, set to ``true`` to grant + the user replication privileges. When omitted on create or + update, the value defaults to ``false`` and replication + privileges are not granted. This option is not currently supported for other database engines. } } @@ -112991,7 +116358,7 @@ async def create_cluster( "project_id": "str", # Optional. The ID of the project that the database cluster is assigned to. If excluded when creating a new database cluster, it will be assigned to your default project.:code:`
`:code:`
`Requires - ``project:update`` scope. + ``project:assign_resource`` scope. "rules": [ { "type": "str", # The type of resource that the firewall rule @@ -113168,9 +116535,10 @@ async def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ], @@ -113472,9 +116840,11 @@ async def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -113845,9 +117215,11 @@ async def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -114039,7 +117411,7 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J "project_id": "str", # Optional. The ID of the project that the database cluster is assigned to. If excluded when creating a new database cluster, it will be assigned to your default project.:code:`
`:code:`
`Requires - ``project:update`` scope. + ``project:assign_resource`` scope. "rules": [ { "type": "str", # The type of resource that the firewall rule @@ -114216,9 +117588,10 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ], @@ -114520,9 +117893,11 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -114956,9 +118331,11 @@ async def get_cluster(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -116770,10 +120147,11 @@ async def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -116842,10 +120220,11 @@ async def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -116886,10 +120265,11 @@ async def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -119302,6 +122682,11 @@ async def list_users(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: For MySQL clusters, additional options will be contained in the mysql_settings object. + For PostgreSQL clusters, additional options will be contained in the ``settings`` + object (for example, ``pg_allow_replication``\\ ). + + For Kafka clusters, additional options will be contained in the ``settings`` object. + For MongoDB clusters, additional information will be contained in the mongo_user_settings object. @@ -119392,9 +122777,10 @@ async def list_users(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ] @@ -119509,10 +122895,14 @@ async def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -119604,9 +122994,11 @@ async def add_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -119681,9 +123073,11 @@ async def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -119720,10 +123114,14 @@ async def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -119815,9 +123213,11 @@ async def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -119849,10 +123249,14 @@ async def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -119941,9 +123345,11 @@ async def add_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -120018,9 +123424,11 @@ async def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -120147,6 +123555,9 @@ async def get_user( For MySQL clusters, additional options will be contained in the ``mysql_settings`` object. + For PostgreSQL clusters, additional options will be contained in the ``settings`` + object (for example, ``pg_allow_replication``\\ ). + For Kafka clusters, additional options will be contained in the ``settings`` object. For MongoDB clusters, additional information will be contained in the mongo_user_settings @@ -120234,9 +123645,11 @@ async def get_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -120465,8 +123878,14 @@ async def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -120535,9 +123954,11 @@ async def update_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -120612,9 +124033,11 @@ async def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -120652,8 +124075,14 @@ async def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -120744,9 +124173,11 @@ async def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -120782,8 +124213,14 @@ async def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -120849,9 +124286,11 @@ async def update_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -120926,9 +124365,11 @@ async def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -121159,9 +124600,11 @@ async def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -121288,9 +124731,11 @@ async def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -121424,9 +124869,11 @@ async def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -134988,9 +138435,10 @@ class of Droplets created from this size. For example: Basic, General The unit of measure for the disk size. }, "type": "str" # Optional. The type - of disk. All Droplets contain a ``local`` disk. Additionally, - GPU Droplets can also have a ``scratch`` disk for - non-persistent data. Known values are: "local" and "scratch". + of disk. All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk + for non-persistent data. Known values are: "local", "remote", + and "scratch". } ], "gpu_info": { @@ -135035,9 +138483,10 @@ class of Droplets created from this size. For example: Basic, General of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -135661,9 +139110,10 @@ async def get(self, droplet_id: int, **kwargs: Any) -> JSON: of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -135706,9 +139156,9 @@ async def get(self, droplet_id: int, **kwargs: Any) -> JSON: measure for the disk size. }, "type": "str" # Optional. The type of disk. All - Droplets contain a ``local`` disk. Additionally, GPU Droplets can - also have a ``scratch`` disk for non-persistent data. Known values - are: "local" and "scratch". + Droplets contain a ``local`` or ``remote`` disk. Additionally, GPU + Droplets can also have a ``scratch`` disk for non-persistent data. + Known values are: "local", "remote", and "scratch". } ], "gpu_info": { @@ -137234,9 +140684,10 @@ class of Droplets created from this size. For example: Basic, General The unit of measure for the disk size. }, "type": "str" # Optional. The type - of disk. All Droplets contain a ``local`` disk. Additionally, - GPU Droplets can also have a ``scratch`` disk for - non-persistent data. Known values are: "local" and "scratch". + of disk. All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk + for non-persistent data. Known values are: "local", "remote", + and "scratch". } ], "gpu_info": { @@ -137281,9 +140732,10 @@ class of Droplets created from this size. For example: Basic, General of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -151452,6 +154904,10 @@ async def list_clusters( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -151764,6 +155220,10 @@ async def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -151983,6 +155443,10 @@ async def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -152235,6 +155699,10 @@ async def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -152473,6 +155941,10 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -152692,6 +156164,10 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -153005,6 +156481,10 @@ async def get_cluster(self, cluster_id: str, **kwargs: Any) -> JSON: "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -153247,6 +156727,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -153443,6 +156927,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -153707,6 +157195,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -153864,6 +157356,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -154060,6 +157556,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -179662,9 +183162,10 @@ async def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -179736,9 +183237,10 @@ async def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -179796,9 +183298,10 @@ async def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -180085,9 +183588,10 @@ async def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Required. :type body: JSON @@ -180152,9 +183656,10 @@ async def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Required. :type body: IO[bytes] @@ -180210,9 +183715,10 @@ async def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Is either a JSON type or a IO[bytes] type. Required. :type body: JSON or IO[bytes] @@ -193501,9 +197007,10 @@ async def list(self, *, per_page: int = 20, page: int = 1, **kwargs: Any) -> JSO of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { diff --git a/src/pydo/gateway/__init__.py b/src/pydo/gateway/__init__.py index 922c5e98..886cad70 100644 --- a/src/pydo/gateway/__init__.py +++ b/src/pydo/gateway/__init__.py @@ -5,7 +5,7 @@ """Action Gateway API — hand-written; preserved across ``make generate``. Session-first surface: create a session on the DigitalOcean API -(``POST /v2/action-gateway/sessions``), then discover/invoke tools and run code +(generated ``POST /v2/sessions``), then discover/invoke tools and run code through the API-returned MCP endpoint. Composio-style providers make session tools plug into pydo inference surfaces (chat completions, messages, responses). """ diff --git a/src/pydo/gateway/session.py b/src/pydo/gateway/session.py index 51243185..7016ed13 100644 --- a/src/pydo/gateway/session.py +++ b/src/pydo/gateway/session.py @@ -10,8 +10,6 @@ import uuid from typing import Any, Dict, List, Optional, Sequence -from azure.core.rest import HttpRequest - from pydo.custom_extensions import _BaseURLProxy from .custom_models import GatewayProtocolError @@ -19,12 +17,9 @@ from .providers import BaseProvider, default_provider, execute_tool_calls from .transport import ( MCPTransport, - _parse_json_body, - _raise_gateway_http_error, resolve_gateway_base_url, ) -_SESSIONS_PATH = "/v2/action-gateway/sessions" _DEFAULT_POLICY: Dict[str, Any] = {"defaultAction": "ask"} @@ -145,7 +140,7 @@ def __repr__(self) -> str: # pragma: no cover - debug aid class SessionsOperations: - """Create sessions via ``POST /v2/action-gateway/sessions`` on the DO API.""" + """Create sessions through the generated ``/v2/sessions`` operation.""" def __init__( self, @@ -155,6 +150,7 @@ def __init__( provider: Optional[BaseProvider] = None, ): self._parent = parent_client + self._sessions_api = parent_client.sessions self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) self._provider = provider or default_provider() @@ -232,25 +228,7 @@ def create( ) def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: - client = self._parent._client - request = HttpRequest( - "POST", - _SESSIONS_PATH, - headers={ - "Content-Type": "application/json", - "Accept": "application/json", - }, - json=body, - ) - request.url = client.format_url(request.url) - pipeline_response = client._pipeline.run(request) - response = pipeline_response.http_response - response_body = ( - response.text() if hasattr(response, "text") else response.body() - ) - if response.status_code not in (200, 201): - _raise_gateway_http_error(response) - payload = _parse_json_body(response_body) + payload = self._sessions_api.create(body=body) if not isinstance(payload, dict): raise GatewayProtocolError( f"unexpected session create response: {payload!r}" diff --git a/src/pydo/gateway/transport.py b/src/pydo/gateway/transport.py index f8937c76..30851c31 100644 --- a/src/pydo/gateway/transport.py +++ b/src/pydo/gateway/transport.py @@ -182,13 +182,6 @@ def _raise_gateway_http_error(response: Any) -> None: "team is not enabled for the Action Infra release " f"(412 Precondition Failed): {message}" ) - if response.status_code == 404 and "/v2/action-gateway/sessions" in ( - getattr(getattr(response, "request", None), "url", "") or "" - ): - message = ( - "session create returned 404 — is POST /v2/action-gateway/sessions " - f"available on this API endpoint? {message}" - ) error_type = _ERROR_MAP.get(response.status_code) if error_type: raise error_type( diff --git a/src/pydo/operations/__init__.py b/src/pydo/operations/__init__.py index 574a164f..3960eadb 100644 --- a/src/pydo/operations/__init__.py +++ b/src/pydo/operations/__init__.py @@ -4,7 +4,11 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- +from ._operations import ToolsOperations from ._operations import ToolbeltsOperations +from ._operations import ConnectionsOperations +from ._operations import UsersOperations +from ._operations import SessionsOperations from ._operations import OneClicksOperations from ._operations import AccountOperations from ._operations import SshKeysOperations @@ -64,7 +68,11 @@ from ._patch import patch_sdk as _patch_sdk __all__ = [ + "ToolsOperations", "ToolbeltsOperations", + "ConnectionsOperations", + "UsersOperations", + "SessionsOperations", "OneClicksOperations", "AccountOperations", "SshKeysOperations", diff --git a/src/pydo/operations/_operations.py b/src/pydo/operations/_operations.py index 69091d8b..8ade4266 100644 --- a/src/pydo/operations/_operations.py +++ b/src/pydo/operations/_operations.py @@ -51,6 +51,101 @@ _SERIALIZER.client_side_validation = False +def build_tools_list_request( + *, + toolkit_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/tools" + + # Construct parameters + if toolkit_id is not None: + _params["toolkit_id"] = _SERIALIZER.query("toolkit_id", toolkit_id, "str") + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_tools_list_toolkits_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/tools/toolkits" + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) + + +def build_tools_list_providers_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/tools/providers" + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) + + +def build_tools_get_definition_request( + name: str, + *, + version: Optional[str] = None, + toolkit_id: Optional[str] = None, + **kwargs: Any, +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/tools/{name}/definition" + path_format_arguments = { + "name": _SERIALIZER.url("name", name, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct parameters + if version is not None: + _params["version"] = _SERIALIZER.query("version", version, "str") + if toolkit_id is not None: + _params["toolkit_id"] = _SERIALIZER.query("toolkit_id", toolkit_id, "str") + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + def build_toolbelts_list_request( *, status: str = "active", page: int = 1, per_page: int = 20, **kwargs: Any ) -> HttpRequest: @@ -210,6 +305,257 @@ def build_toolbelts_delete_tools_request(name: str, **kwargs: Any) -> HttpReques return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) +def build_connections_list_request( + *, + provider: Optional[str] = None, + user_id: Optional[str] = None, + status: Optional[str] = None, + sort: Optional[str] = None, + sort_direction: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/connections" + + # Construct parameters + if provider is not None: + _params["provider"] = _SERIALIZER.query("provider", provider, "str") + if user_id is not None: + _params["user_id"] = _SERIALIZER.query("user_id", user_id, "str") + if status is not None: + _params["status"] = _SERIALIZER.query("status", status, "str") + if sort is not None: + _params["sort"] = _SERIALIZER.query("sort", sort, "str") + if sort_direction is not None: + _params["sort_direction"] = _SERIALIZER.query( + "sort_direction", sort_direction, "str" + ) + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_connections_create_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/connections" + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + +def build_connections_get_request(id: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/connections/{id}" + path_format_arguments = { + "id": _SERIALIZER.url("id", id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) + + +def build_connections_update_request(id: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/connections/{id}" + path_format_arguments = { + "id": _SERIALIZER.url("id", id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="PATCH", url=_url, headers=_headers, **kwargs) + + +def build_connections_delete_request(id: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/connections/{id}" + path_format_arguments = { + "id": _SERIALIZER.url("id", id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) + + +def build_users_list_request( + *, page: int = 1, per_page: int = 20, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/users" + + # Construct parameters + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_users_get_request(user_id: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/users/{user_id}" + path_format_arguments = { + "user_id": _SERIALIZER.url("user_id", user_id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) + + +def build_sessions_list_request( + *, + end_user_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/sessions" + + # Construct parameters + if end_user_id is not None: + _params["end_user_id"] = _SERIALIZER.query("end_user_id", end_user_id, "str") + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_sessions_create_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/sessions" + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + +def build_sessions_delete_request(session_urn: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/sessions/{session_urn}" + path_format_arguments = { + "session_urn": _SERIALIZER.url("session_urn", session_urn, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) + + def build_one_clicks_list_request( *, type: Optional[str] = None, **kwargs: Any ) -> HttpRequest: @@ -233,9 +579,9 @@ def build_one_clicks_list_request( ) -def build_one_clicks_install_kubernetes_request( +def build_one_clicks_install_kubernetes_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -1640,9 +1986,9 @@ def build_apps_get_metrics_bandwidth_daily_request( # pylint: disable=name-too- ) -def build_apps_list_metrics_bandwidth_daily_request( +def build_apps_list_metrics_bandwidth_daily_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -4021,9 +4367,9 @@ def build_dedicated_inferences_list_request( ) -def build_dedicated_inferences_create_request( +def build_dedicated_inferences_create_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -4220,9 +4566,9 @@ def build_dedicated_inferences_delete_tokens_request( # pylint: disable=name-to return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) -def build_dedicated_inferences_list_sizes_request( +def build_dedicated_inferences_list_sizes_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -4962,9 +5308,9 @@ def build_droplets_destroy_retry_with_associated_resources_request( # pylint: d return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_droplets_list_neighbors_ids_request( +def build_droplets_list_neighbors_ids_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -6832,9 +7178,9 @@ def build_kubernetes_add_registries_request(**kwargs: Any) -> HttpRequest: return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_kubernetes_remove_registries_request( +def build_kubernetes_remove_registries_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -7155,9 +7501,9 @@ def build_monitoring_list_alert_policy_request( # pylint: disable=name-too-long ) -def build_monitoring_create_alert_policy_request( +def build_monitoring_create_alert_policy_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -8691,9 +9037,9 @@ def build_monitoring_get_database_mysql_schema_latency_request( # pylint: disab ) -def build_monitoring_create_destination_request( +def build_monitoring_create_destination_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -8714,9 +9060,9 @@ def build_monitoring_create_destination_request( return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_monitoring_list_destinations_request( +def build_monitoring_list_destinations_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -9657,9 +10003,9 @@ def build_projects_assign_resources_request( return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_projects_list_resources_default_request( +def build_projects_list_resources_default_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -9673,9 +10019,9 @@ def build_projects_list_resources_default_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_projects_assign_resources_default_request( +def build_projects_assign_resources_default_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -9817,9 +10163,9 @@ def build_registries_get_docker_credentials_request( # pylint: disable=name-too return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_registries_get_subscription_request( +def build_registries_get_subscription_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -9833,9 +10179,9 @@ def build_registries_get_subscription_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_registries_update_subscription_request( +def build_registries_update_subscription_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -10242,9 +10588,9 @@ def build_registry_get_subscription_request(**kwargs: Any) -> HttpRequest: return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_registry_update_subscription_request( +def build_registry_update_subscription_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -11263,9 +11609,9 @@ def build_security_list_settings_request( ) -def build_security_update_settings_plan_request( +def build_security_update_settings_plan_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -11286,9 +11632,9 @@ def build_security_update_settings_plan_request( return HttpRequest(method="PUT", url=_url, headers=_headers, **kwargs) -def build_security_create_suppression_request( +def build_security_create_suppression_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -12032,9 +12378,9 @@ def build_vector_databases_delete_request(id: str, **kwargs: Any) -> HttpRequest return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) -def build_vector_databases_list_backups_request( +def build_vector_databases_list_backups_request( # pylint: disable=name-too-long id: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -12125,9 +12471,9 @@ def build_vector_databases_get_credentials_request( # pylint: disable=name-too- return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_vector_databases_post_resize_request( +def build_vector_databases_post_resize_request( # pylint: disable=name-too-long id: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -12153,9 +12499,9 @@ def build_vector_databases_post_resize_request( return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_vector_databases_update_tags_request( +def build_vector_databases_update_tags_request( # pylint: disable=name-too-long id: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14017,9 +14363,9 @@ def build_genai_list_anthropic_api_keys_request( # pylint: disable=name-too-lon ) -def build_genai_create_anthropic_api_key_request( +def build_genai_create_anthropic_api_key_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14285,9 +14631,9 @@ def build_genai_list_evaluation_datasets_request( # pylint: disable=name-too-lo ) -def build_genai_create_evaluation_dataset_request( +def build_genai_create_evaluation_dataset_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14373,9 +14719,9 @@ def build_genai_get_evaluation_dataset_download_url_request( # pylint: disable= return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_list_evaluation_metrics_request( +def build_genai_list_evaluation_metrics_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -14389,9 +14735,9 @@ def build_genai_list_evaluation_metrics_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_create_custom_evaluation_metric_request( +def build_genai_create_custom_evaluation_metric_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14461,9 +14807,9 @@ def build_genai_delete_custom_evaluation_metric_request( # pylint: disable=name return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) -def build_genai_run_evaluation_test_case_request( +def build_genai_run_evaluation_test_case_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14567,9 +14913,9 @@ def build_genai_get_evaluation_run_prompt_results_request( # pylint: disable=na return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_list_evaluation_test_cases_request( +def build_genai_list_evaluation_test_cases_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -14583,9 +14929,9 @@ def build_genai_list_evaluation_test_cases_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_create_evaluation_test_case_request( +def build_genai_create_evaluation_test_case_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14866,9 +15212,9 @@ def build_genai_list_knowledge_bases_request( ) -def build_genai_create_knowledge_base_request( +def build_genai_create_knowledge_base_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -15079,9 +15425,9 @@ def build_genai_get_knowledge_base_request(uuid: str, **kwargs: Any) -> HttpRequ return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_update_knowledge_base_request( +def build_genai_update_knowledge_base_request( # pylint: disable=name-too-long uuid: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -15107,9 +15453,9 @@ def build_genai_update_knowledge_base_request( return HttpRequest(method="PUT", url=_url, headers=_headers, **kwargs) -def build_genai_delete_knowledge_base_request( +def build_genai_delete_knowledge_base_request( # pylint: disable=name-too-long uuid: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -15151,9 +15497,9 @@ def build_genai_create_model_eval_dataset_upload_presigned_urls_request( # pyli return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_genai_list_model_evaluation_metrics_request( +def build_genai_list_model_evaluation_metrics_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -15167,9 +15513,9 @@ def build_genai_list_model_evaluation_metrics_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_list_model_evaluation_presets_request( +def build_genai_list_model_evaluation_presets_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -15284,9 +15630,9 @@ def build_genai_list_model_evaluation_runs_request( # pylint: disable=name-too- ) -def build_genai_create_model_evaluation_run_request( +def build_genai_create_model_evaluation_run_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -15802,9 +16148,9 @@ def build_genai_delete_model_router_request(uuid: str, **kwargs: Any) -> HttpReq return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) -def build_genai_create_oauth2_dropbox_tokens_request( +def build_genai_create_oauth2_dropbox_tokens_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -15875,9 +16221,9 @@ def build_genai_list_openai_api_keys_request( ) -def build_genai_create_openai_api_key_request( +def build_genai_create_openai_api_key_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -16034,9 +16380,9 @@ def build_genai_list_datacenter_regions_request( # pylint: disable=name-too-lon ) -def build_genai_create_scheduled_indexing_request( +def build_genai_create_scheduled_indexing_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -16294,9 +16640,9 @@ def build_genai_list_evaluation_test_cases_by_workspace_request( # pylint: disa return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_inference_create_chat_completion_request( +def build_inference_create_chat_completion_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -16415,9 +16761,9 @@ def build_inference_create_response_request(**kwargs: Any) -> HttpRequest: return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_inference_create_async_invoke_request( +def build_inference_create_async_invoke_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -16438,9 +16784,9 @@ def build_inference_create_async_invoke_request( return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_inference_create_batch_file_request( +def build_inference_create_batch_file_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -16628,14 +16974,14 @@ def build_agent_inference_create_chat_completion_request( # pylint: disable=nam ) -class ToolbeltsOperations: +class ToolsOperations: """ .. warning:: **DO NOT** instantiate this class directly. Instead, you should access the following operations through :class:`~pydo.GeneratedClient`'s - :attr:`toolbelts` attribute. + :attr:`tools` attribute. """ def __init__(self, *args, **kwargs): @@ -16651,18 +16997,18 @@ def __init__(self, *args, **kwargs): def list( self, *, - status: str = "active", + toolkit_id: Optional[str] = None, page: int = 1, per_page: int = 20, **kwargs: Any, ) -> JSON: - """List Toolbelts. + # pylint: disable=line-too-long + """List Tools. - Lists the latest version of each toolbelt owned by the authenticated team. + Lists active Action Gateway tools visible to the authenticated team. - :keyword status: Filter toolbelts by status. Known values are: "active", "deprecated", and - "all". Default value is "active". - :paramtype status: str + :keyword toolkit_id: Filter tools by toolkit identifier. Default value is None. + :paramtype toolkit_id: str :keyword page: Which 'page' of paginated results to return. Default value is 1. :paramtype page: int :keyword per_page: Number of items returned per page. Default value is 20. @@ -16676,23 +17022,993 @@ def list( # response body for status code(s): 200 response == { + "definitions": [ + { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "auth": { + "baseUrlResolution": { + "httpLookup": { + "baseUrlTemplate": "str", # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "caseInsensitive": bool, # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "extractField": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "matchField": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "matchValue": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "match_value_parameter": "str", # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "method": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "requiredScopes": [ + "str" # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access + token), selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When + both are empty, exactly one entry whose own "scopes" + array contains required_scopes must exist. Configuring + only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + ], + "trimTrailingSlash": bool, # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "url": "str" # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + } + }, + "credentialBinding": "str", # Optional. + "credentialRefSource": "str", # Optional. + "doManagedCredentialRef": "str", # Optional. + "injection": { + "location": "str", # Optional. + "name": "str", # Optional. + "scheme": "str" # Optional. + }, + "modes": [ + "str" # Optional. + ], + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + }, + "classification": { + "dataClasses": [ + "str" # Optional. + ], + "operation": "str", # Optional. + "risk": "str" # Optional. + }, + "description": "str", # Optional. + "execution": { + "adapterVersion": "str", # Optional. + "configRef": "str", # Optional. + "http": { + "allowedHosts": [ + "str" # Optional. + ], + "baseUrl": "str", # Optional. + "method": "str", # Optional. + "path": "str", # Optional. + "requestEncoding": "str", # Optional. + "responseFormat": "str" # Optional. + }, + "mcp": { + "allowedHosts": [ + "str" # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote + MCP server (as opposed to a plain HTTP endpoint). endpoint is + the remote MCP server's URL, tool_name is the name the remote + server expects on tools/call (may differ from this tool's + registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and + server_ref is an opaque label identifying the remote server + for logging/metrics/allowlisting. + ], + "endpoint": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "serverRef": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "toolName": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "transport": "str" # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + }, + "type": "str" # Optional. + }, + "flipperName": "str", # Optional. + "hooks": { + "usage": { + "billable": bool, # Optional. When usage + metadata is present, false prevents billing. Omitting usage + metadata leaves consumers' legacy billing classification + unchanged. + "meters": [ + { + "quantitySource": "str", # + Optional. + "sku": "str", # Optional. + "unit": "str" # Optional. + } + ] + } + }, + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "policy": { + "permission": "str" # Optional. + }, + "reliability": { + "maxOutputBytes": "str", # Optional. + "retry": { + "backoff": "str", # Optional. + "maxAttempts": 0, # Optional. + "retryOn": [ + "str" # Optional. + ] + }, + "timeoutMs": 0 # Optional. + }, + "schemaVersion": "str", # Optional. + "status": "str", # Optional. + "streamingSafe": bool, # Optional. + "tags": [ + "str" # Optional. + ], + "title": "str", # Optional. + "toolId": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the + provider-qualified, stable tool identifier + ":code:``_:code:``". Pass this value back verbatim to + the toolbelt add/remove endpoints; clients should treat it as opaque. + "toolkitId": "str", # Optional. + "transform": { + "input": {}, # Optional. Any object. + "language": "str", # Optional. + "output": {} # Optional. Any object. + }, + "version": "str" # Optional. + } + ], "pagination": { "page": 0, # Required. "per_page": 0, # Required. "total": 0 # Required. }, - "toolbelts": [ + "tools": [ { - "latest_version": "str", # Required. - "name": "str", # Required. - "reference_latest": "str", # Required. - "status": "str", # Required. Known values are: "active" and - "deprecated". - "tool_count": 0, # Required. - "updated_at": "2020-02-20 00:00:00", # Required. - "version_count": 0, # Required. - "description": "str", # Optional. Required. - "display_name": "str" # Optional. Required. + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "description": "str", # Optional. + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "streamingSafe": bool, # Optional. + "title": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the + provider-qualified, stable tool identifier + ":code:``_:code:``". Pass this value back verbatim to + the toolbelt add/remove endpoints; clients should treat it as opaque + rather than reconstructing it from toolkit_id and name. + "toolkitId": "str", # Optional. + "version": "str" # Optional. + } + ], + "version": "str" # Optional. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_request( + toolkit_id=toolkit_id, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def list_toolkits(self, **kwargs: Any) -> JSON: + """List Toolkits. + + Lists the toolkits that group Action Gateway tools. + + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolkits": [ + { + "description": "str", # Optional. + "id": "str", # Optional. + "name": "str" # Optional. + } + ], + "version": "str" # Optional. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_toolkits_request( + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def list_providers(self, **kwargs: Any) -> JSON: + """List Tool Providers. + + Lists Action Gateway providers and their connection requirements. + + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "providers": [ + { + "auth_type": "str", # Optional. + "connection_parameters": [ + { + "allowed_host_suffixes": [ + "str" # Optional. + ], + "allowed_values": [ + "str" # Optional. + ], + "description": "str", # Optional. + "input_kind": "str", # Optional. + "key": "str", # Optional. + "label": "str", # Optional. + "max_length": 0, # Optional. + "normalization": "str", # Optional. + "required": bool # Optional. + } + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "name": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_providers_request( + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def get_definition( + self, + name: str, + *, + version: Optional[str] = None, + toolkit_id: Optional[str] = None, + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Tool Definition. + + Retrieves the executable definition for an active Action Gateway tool. + + :param name: The provider-qualified tool name. Required. + :type name: str + :keyword version: The tool version. Omit to retrieve the current version. Default value is + None. + :paramtype version: str + :keyword toolkit_id: The toolkit identifier used to disambiguate a bare tool name. Default + value is None. + :paramtype toolkit_id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "auth": { + "baseUrlResolution": { + "httpLookup": { + "baseUrlTemplate": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "caseInsensitive": bool, # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "extractField": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "matchField": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "matchValue": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "match_value_parameter": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting extract_field from + that entry, and substituting it for "{value}" in base_url_template. + When match_field and match_value are both set, they select the entry. + When both are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one match field + is invalid. Resolution fails fast on zero or multiple compatible + entries. + "method": "str", # Optional. HTTPLookupSpec resolves + a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "requiredScopes": [ + "str" # Optional. HTTPLookupSpec resolves a + base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON + array, extracting extract_field from that entry, and substituting + it for "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both are + empty, exactly one entry whose own "scopes" array contains + required_scopes must exist. Configuring only one match field is + invalid. Resolution fails fast on zero or multiple compatible + entries. + ], + "trimTrailingSlash": bool, # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting extract_field from + that entry, and substituting it for "{value}" in base_url_template. + When match_field and match_value are both set, they select the entry. + When both are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one match field + is invalid. Resolution fails fast on zero or multiple compatible + entries. + "url": "str" # Optional. HTTPLookupSpec resolves a + base_url by calling url (bearer-authenticated with the just-exchanged + access token), selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for "{value}" in + base_url_template. When match_field and match_value are both set, + they select the entry. When both are empty, exactly one entry whose + own "scopes" array contains required_scopes must exist. Configuring + only one match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + } + }, + "credentialBinding": "str", # Optional. + "credentialRefSource": "str", # Optional. + "doManagedCredentialRef": "str", # Optional. + "injection": { + "location": "str", # Optional. + "name": "str", # Optional. + "scheme": "str" # Optional. + }, + "modes": [ + "str" # Optional. + ], + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + }, + "classification": { + "dataClasses": [ + "str" # Optional. + ], + "operation": "str", # Optional. + "risk": "str" # Optional. + }, + "description": "str", # Optional. + "execution": { + "adapterVersion": "str", # Optional. + "configRef": "str", # Optional. + "http": { + "allowedHosts": [ + "str" # Optional. + ], + "baseUrl": "str", # Optional. + "method": "str", # Optional. + "path": "str", # Optional. + "requestEncoding": "str", # Optional. + "responseFormat": "str" # Optional. + }, + "mcp": { + "allowedHosts": [ + "str" # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, + tool_name is the name the remote server expects on tools/call (may + differ from this tool's registry name), transport selects the wire + protocol ("streamable_http" is the only kind implemented today), and + server_ref is an opaque label identifying the remote server for + logging/metrics/allowlisting. + ], + "endpoint": "str", # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "serverRef": "str", # Optional. MCPExecution describes how + to invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "toolName": "str", # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "transport": "str" # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + }, + "type": "str" # Optional. + }, + "flipperName": "str", # Optional. + "hooks": { + "usage": { + "billable": bool, # Optional. When usage metadata is + present, false prevents billing. Omitting usage metadata leaves + consumers' legacy billing classification unchanged. + "meters": [ + { + "quantitySource": "str", # Optional. + "sku": "str", # Optional. + "unit": "str" # Optional. + } + ] + } + }, + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "policy": { + "permission": "str" # Optional. + }, + "reliability": { + "maxOutputBytes": "str", # Optional. + "retry": { + "backoff": "str", # Optional. + "maxAttempts": 0, # Optional. + "retryOn": [ + "str" # Optional. + ] + }, + "timeoutMs": 0 # Optional. + }, + "schemaVersion": "str", # Optional. + "status": "str", # Optional. + "streamingSafe": bool, # Optional. + "tags": [ + "str" # Optional. + ], + "title": "str", # Optional. + "toolId": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the provider-qualified, stable + tool identifier ":code:``_:code:``". Pass this value back + verbatim to the toolbelt add/remove endpoints; clients should treat it as opaque. + "toolkitId": "str", # Optional. + "transform": { + "input": {}, # Optional. Any object. + "language": "str", # Optional. + "output": {} # Optional. Any object. + }, + "version": "str" # Optional. + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_get_definition_request( + name=name, + version=version, + toolkit_id=toolkit_id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class ToolbeltsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`toolbelts` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list( + self, + *, + status: str = "active", + page: int = 1, + per_page: int = 20, + **kwargs: Any, + ) -> JSON: + """List Toolbelts. + + Lists the latest version of each toolbelt owned by the authenticated team. + + :keyword status: Filter toolbelts by status. Known values are: "active", "deprecated", and + "all". Default value is "active". + :paramtype status: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "toolbelts": [ + { + "latest_version": "str", # Required. + "name": "str", # Required. + "reference_latest": "str", # Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "updated_at": "2020-02-20 00:00:00", # Required. + "version_count": 0, # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. } ] } @@ -17188,13 +18504,34 @@ def delete(self, name: str, **kwargs: Any) -> JSON: :param name: The natural key identifying the toolbelt. Required. :type name: str - :return: JSON or JSON object + :return: JSON object :rtype: JSON :raises ~azure.core.exceptions.HttpResponseError: Example: .. code-block:: python + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } # response body for status code(s): 404 response == { "id": "str", # A short identifier corresponding to the HTTP status code @@ -17561,207 +18898,2595 @@ def add_tools(self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J else: deserialized = None - if response.status_code == 404: - response_headers["ratelimit-limit"] = self._deserialize( - "int", response.headers.get("ratelimit-limit") - ) - response_headers["ratelimit-remaining"] = self._deserialize( - "int", response.headers.get("ratelimit-remaining") - ) - response_headers["ratelimit-reset"] = self._deserialize( - "int", response.headers.get("ratelimit-reset") - ) - - if response.content: - deserialized = response.json() - else: - deserialized = None - + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def delete_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def delete_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def delete_tools( + self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_delete_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class ConnectionsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`connections` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list( + self, + *, + provider: Optional[str] = None, + user_id: Optional[str] = None, + status: Optional[str] = None, + sort: Optional[str] = None, + sort_direction: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, + ) -> JSON: + """List Connections. + + Lists OAuth connections owned by the authenticated team. + + :keyword provider: Filter by provider name. Default value is None. + :paramtype provider: str + :keyword user_id: Filter by end-user identifier. Default value is None. + :paramtype user_id: str + :keyword status: Filter by connection status. Default value is None. + :paramtype status: str + :keyword sort: Field used to sort results. Default value is None. + :paramtype sort: str + :keyword sort_direction: Sort direction. Default value is None. + :paramtype sort_direction: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connections": [ + { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. + "granted_at": "2020-02-20 00:00:00", # Optional. + "id": "str", # Optional. + "provider": "str", # Optional. + "provider_display_name": "str", # Optional. + "revoked_at": "2020-02-20 00:00:00", # Optional. + "scopes": [ + "str" # Optional. + ], + "status": "str", # Optional. + "updated_at": "2020-02-20 00:00:00", # Optional. + "user_id": "str" # Optional. + } + ], + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + } + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_list_request( + provider=provider, + user_id=user_id, + status=status, + sort=sort, + sort_direction=sort_direction, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ], + "user_id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ], + "user_id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_connections_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 409]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 409: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def get(self, id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Connection. + + Retrieves an OAuth connection owned by the authenticated team. + + :param id: The connection UUID. Required. + :type id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_get_request( + id=id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def update( + self, + id: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def update( + self, + id: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def update(self, id: str, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_connections_update_request( + id=id, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def delete(self, id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete a Connection. + + Revokes and deletes an OAuth connection owned by the authenticated team. + + :param id: The connection UUID. Required. + :type id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_delete_request( + id=id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class UsersOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`users` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list(self, *, page: int = 1, per_page: int = 20, **kwargs: Any) -> JSON: + """List Action Gateway Users. + + Lists end-user identifiers derived from sessions and OAuth connections for the authenticated + team. + + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "user_ids": [ + "str" # Optional. + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_users_list_request( + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def get(self, user_id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve an Action Gateway User. + + Retrieves a derived end-user view containing its sessions and OAuth connections. + + :param user_id: The end-user identifier. Required. + :type user_id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "user": { + "connections": [ + { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "granted_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "id": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "provider": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "provider_display_name": "str", # Optional. User is + a derived, team-scoped view across sessions and OAuth connections. + "revoked_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "scopes": [ + "str" # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + ], + "status": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "updated_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "user_id": "str" # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + } + ], + "sessions": [ + { + "created_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "name": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "session_urn": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "updated_at": "2020-02-20 00:00:00" # Optional. User + is a derived, team-scoped view across sessions and OAuth connections. + } + ], + "user_id": "str" # Optional. User is a derived, team-scoped view + across sessions and OAuth connections. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_users_get_request( + user_id=user_id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class SessionsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`sessions` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list( + self, + *, + end_user_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """List Action Gateway Sessions. + + Lists Action Gateway sessions owned by the authenticated team. + + :keyword end_user_id: Filter sessions by actor identifier. Default value is None. + :paramtype end_user_id: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "sessions": [ + { + "actorId": "str", # Optional. actor_id is empty when the + session is not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. + Gateway currently interprets config.preloadTools to add selected direct + tools to the session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. + "name": "str", # Optional. name is the required + human-readable session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is + "ask". SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default + value is "ask". SessionPolicyAction is the disposition + applied to a tool call. Lowercase values are canonical so + ProtoJSON matches the public REST vocabulary; the prefixed + aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. + Dictionary of :code:``. + }, + "tool": "str" # Optional. + SessionPolicySpec is the Gateway-relevant subset of a + session's permission policy. Filesystem and network policy + remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known + values are: "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted + when the request omitted tools (all tools). A present + selection with no references represents tools: []. + "version": "str" # Optional. Omitted + when the request omitted tools (all tools). A present + selection with no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_sessions_list_request( + end_user_id=end_user_id, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "actor_id": "str", # Required. + "name": "str", # Required. + "config": { + "preloadTools": [ + "str" # Optional. Concrete tools or pinned toolbelts to + expose directly beside the session meta-tools. + ] + }, + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. Known + values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary of + :code:``. + }, + "tool": "str" # Optional. Invocation policy. Omit to + use a default action of ask. + } + ] + }, + "tools": [ + "str" # Optional. Omitted enables every tool. An explicit empty + array enables no tools. Direct tools may be :code:`` or + @:code:``; toolbelt references must be version-pinned as + toolbelt::code:``@:code:``. + ] + } + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "actor_id": "str", # Required. + "name": "str", # Required. + "config": { + "preloadTools": [ + "str" # Optional. Concrete tools or pinned toolbelts to + expose directly beside the session meta-tools. + ] + }, + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. Known + values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary of + :code:``. + }, + "tool": "str" # Optional. Invocation policy. Omit to + use a default action of ask. + } + ] + }, + "tools": [ + "str" # Optional. Omitted enables every tool. An explicit empty + array enables no tools. Direct tools may be :code:`` or + @:code:``; toolbelt references must be version-pinned as + toolbelt::code:``@:code:``. + ] + } + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_sessions_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + if cls: return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore return cast(JSON, deserialized) # type: ignore - @overload - def delete_tools( - self, - name: str, - body: JSON, - *, - content_type: str = "application/json", - **kwargs: Any, - ) -> JSON: - # pylint: disable=line-too-long - """Remove Tools from a Toolbelt. - - Removes tool names and creates a new immutable toolbelt version. - - :param name: The natural key identifying the toolbelt. Required. - :type name: str - :param body: Required. - :type body: JSON - :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. - Default value is "application/json". - :paramtype content_type: str - :return: JSON object - :rtype: JSON - :raises ~azure.core.exceptions.HttpResponseError: - - Example: - .. code-block:: python - - # JSON input template you can fill out and use as your body input. - body = { - "tools": [ - "str" # Required. - ] - } - - # response body for status code(s): 200 - response == { - "toolbelt": { - "created_at": "2020-02-20 00:00:00", # Required. - "name": "str", # Required. - "reference": "str", # A reference pinned to this immutable toolbelt - version. Required. - "reference_latest": "str", # An unversioned reference to the latest - active version. Required. - "status": "str", # Required. Known values are: "active" and - "deprecated". - "tool_count": 0, # Required. - "tools": [ - "str" # Required. - ], - "updated_at": "2020-02-20 00:00:00", # Required. - "version": "str", # Required. - "description": "str", # Optional. Required. - "display_name": "str" # Optional. Required. - } - } - # response body for status code(s): 400, 404 - response == { - "id": "str", # A short identifier corresponding to the HTTP status code - returned. For example, the ID for a response returning a 404 status code would - be "not_found.". Required. - "message": "str", # A message providing additional information about the - error, including details to help resolve it when possible. Required. - "request_id": "str" # Optional. Optionally, some endpoints may include a - request ID that should be provided when reporting bugs or opening support - tickets to help identify the issue. - } - """ - - @overload - def delete_tools( - self, - name: str, - body: IO[bytes], - *, - content_type: str = "application/json", - **kwargs: Any, - ) -> JSON: - # pylint: disable=line-too-long - """Remove Tools from a Toolbelt. - - Removes tool names and creates a new immutable toolbelt version. - - :param name: The natural key identifying the toolbelt. Required. - :type name: str - :param body: Required. - :type body: IO[bytes] - :keyword content_type: Body Parameter content-type. Content type parameter for binary body. - Default value is "application/json". - :paramtype content_type: str - :return: JSON object - :rtype: JSON - :raises ~azure.core.exceptions.HttpResponseError: - - Example: - .. code-block:: python - - # response body for status code(s): 200 - response == { - "toolbelt": { - "created_at": "2020-02-20 00:00:00", # Required. - "name": "str", # Required. - "reference": "str", # A reference pinned to this immutable toolbelt - version. Required. - "reference_latest": "str", # An unversioned reference to the latest - active version. Required. - "status": "str", # Required. Known values are: "active" and - "deprecated". - "tool_count": 0, # Required. - "tools": [ - "str" # Required. - ], - "updated_at": "2020-02-20 00:00:00", # Required. - "version": "str", # Required. - "description": "str", # Optional. Required. - "display_name": "str" # Optional. Required. - } - } - # response body for status code(s): 400, 404 - response == { - "id": "str", # A short identifier corresponding to the HTTP status code - returned. For example, the ID for a response returning a 404 status code would - be "not_found.". Required. - "message": "str", # A message providing additional information about the - error, including details to help resolve it when possible. Required. - "request_id": "str" # Optional. Optionally, some endpoints may include a - request ID that should be provided when reporting bugs or opening support - tickets to help identify the issue. - } - """ - @distributed_trace - def delete_tools( - self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any - ) -> JSON: + def delete(self, session_urn: str, **kwargs: Any) -> JSON: # pylint: disable=line-too-long - """Remove Tools from a Toolbelt. + """Delete an Action Gateway Session. - Removes tool names and creates a new immutable toolbelt version. + Deletes an Action Gateway session owned by the authenticated team. - :param name: The natural key identifying the toolbelt. Required. - :type name: str - :param body: Is either a JSON type or a IO[bytes] type. Required. - :type body: JSON or IO[bytes] - :return: JSON object + :param session_urn: The URL-encoded managed agents session URN. Required. + :type session_urn: str + :return: JSON or JSON object :rtype: JSON :raises ~azure.core.exceptions.HttpResponseError: Example: .. code-block:: python - # JSON input template you can fill out and use as your body input. - body = { - "tools": [ - "str" # Required. - ] - } - - # response body for status code(s): 200 - response == { - "toolbelt": { - "created_at": "2020-02-20 00:00:00", # Required. - "name": "str", # Required. - "reference": "str", # A reference pinned to this immutable toolbelt - version. Required. - "reference_latest": "str", # An unversioned reference to the latest - active version. Required. - "status": "str", # Required. Known values are: "active" and - "deprecated". - "tool_count": 0, # Required. - "tools": [ - "str" # Required. - ], - "updated_at": "2020-02-20 00:00:00", # Required. - "version": "str", # Required. - "description": "str", # Optional. Required. - "display_name": "str" # Optional. Required. - } - } - # response body for status code(s): 400, 404 + # response body for status code(s): 404 response == { "id": "str", # A short identifier corresponding to the HTTP status code returned. For example, the ID for a response returning a 404 status code would @@ -17786,27 +21511,13 @@ def delete_tools( } error_map.update(kwargs.pop("error_map", {}) or {}) - _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - content_type: Optional[str] = kwargs.pop( - "content_type", _headers.pop("Content-Type", None) - ) cls: ClsType[JSON] = kwargs.pop("cls", None) - content_type = content_type or "application/json" - _json = None - _content = None - if isinstance(body, (IOBase, bytes)): - _content = body - else: - _json = body - - _request = build_toolbelts_delete_tools_request( - name=name, - content_type=content_type, - json=_json, - content=_content, + _request = build_sessions_delete_request( + session_urn=session_urn, headers=_headers, params=_params, ) @@ -17821,7 +21532,7 @@ def delete_tools( response = pipeline_response.http_response - if response.status_code not in [200, 400, 404]: + if response.status_code not in [200, 404]: if _stream: response.read() # Load the body in memory and close the socket map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore @@ -17844,22 +21555,6 @@ def delete_tools( else: deserialized = None - if response.status_code == 400: - response_headers["ratelimit-limit"] = self._deserialize( - "int", response.headers.get("ratelimit-limit") - ) - response_headers["ratelimit-remaining"] = self._deserialize( - "int", response.headers.get("ratelimit-remaining") - ) - response_headers["ratelimit-reset"] = self._deserialize( - "int", response.headers.get("ratelimit-reset") - ) - - if response.content: - deserialized = response.json() - else: - deserialized = None - if response.status_code == 404: response_headers["ratelimit-limit"] = self._deserialize( "int", response.headers.get("ratelimit-limit") @@ -30620,7 +34315,7 @@ def create( }, "project_id": "str" # Optional. The ID of the project the app should be assigned to. If omitted, it will be assigned to your default project. - :code:`
`:code:`
`Requires ``project:update`` scope. + :code:`
`:code:`
`Requires ``project:assign_resource`` scope. } # response body for status code(s): 200 @@ -47343,7 +51038,7 @@ def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: }, "project_id": "str" # Optional. The ID of the project the app should be assigned to. If omitted, it will be assigned to your default project. - :code:`
`:code:`
`Requires ``project:update`` scope. + :code:`
`:code:`
`Requires ``project:assign_resource`` scope. } # response body for status code(s): 200 @@ -128610,8 +132305,10 @@ def list_clusters(self, *, tag_name: Optional[str] = None, **kwargs: Any) -> JSO } ], "pg_allow_replication": bool # - Optional. For Postgres clusters, set to ``true`` for a user - with replication rights. This option is not currently + Optional. For PostgreSQL clusters, set to ``true`` to grant + the user replication privileges. When omitted on create or + update, the value defaults to ``false`` and replication + privileges are not granted. This option is not currently supported for other database engines. } } @@ -128887,7 +132584,7 @@ def create_cluster( "project_id": "str", # Optional. The ID of the project that the database cluster is assigned to. If excluded when creating a new database cluster, it will be assigned to your default project.:code:`
`:code:`
`Requires - ``project:update`` scope. + ``project:assign_resource`` scope. "rules": [ { "type": "str", # The type of resource that the firewall rule @@ -129064,9 +132761,10 @@ def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ], @@ -129368,9 +133066,11 @@ def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -129741,9 +133441,11 @@ def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -129935,7 +133637,7 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: "project_id": "str", # Optional. The ID of the project that the database cluster is assigned to. If excluded when creating a new database cluster, it will be assigned to your default project.:code:`
`:code:`
`Requires - ``project:update`` scope. + ``project:assign_resource`` scope. "rules": [ { "type": "str", # The type of resource that the firewall rule @@ -130112,9 +133814,10 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ], @@ -130416,9 +134119,11 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -130852,9 +134557,11 @@ def get_cluster(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -132662,10 +136369,11 @@ def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -132734,10 +136442,11 @@ def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -132778,10 +136487,11 @@ def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -135194,6 +138904,11 @@ def list_users(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: For MySQL clusters, additional options will be contained in the mysql_settings object. + For PostgreSQL clusters, additional options will be contained in the ``settings`` + object (for example, ``pg_allow_replication``\\ ). + + For Kafka clusters, additional options will be contained in the ``settings`` object. + For MongoDB clusters, additional information will be contained in the mongo_user_settings object. @@ -135284,9 +138999,10 @@ def list_users(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ] @@ -135401,10 +139117,14 @@ def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -135496,9 +139216,11 @@ def add_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -135573,9 +139295,11 @@ def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -135612,10 +139336,14 @@ def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -135707,9 +139435,11 @@ def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -135741,10 +139471,14 @@ def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -135833,9 +139567,11 @@ def add_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -135910,9 +139646,11 @@ def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -136039,6 +139777,9 @@ def get_user( For MySQL clusters, additional options will be contained in the ``mysql_settings`` object. + For PostgreSQL clusters, additional options will be contained in the ``settings`` + object (for example, ``pg_allow_replication``\\ ). + For Kafka clusters, additional options will be contained in the ``settings`` object. For MongoDB clusters, additional information will be contained in the mongo_user_settings @@ -136126,9 +139867,11 @@ def get_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -136357,8 +140100,14 @@ def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -136427,9 +140176,11 @@ def update_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -136504,9 +140255,11 @@ def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -136544,8 +140297,14 @@ def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -136636,9 +140395,11 @@ def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -136674,8 +140435,14 @@ def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -136741,9 +140508,11 @@ def update_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -136818,9 +140587,11 @@ def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -137051,9 +140822,11 @@ def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -137180,9 +140953,11 @@ def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -137316,9 +141091,11 @@ def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -150870,9 +154647,10 @@ class of Droplets created from this size. For example: Basic, General The unit of measure for the disk size. }, "type": "str" # Optional. The type - of disk. All Droplets contain a ``local`` disk. Additionally, - GPU Droplets can also have a ``scratch`` disk for - non-persistent data. Known values are: "local" and "scratch". + of disk. All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk + for non-persistent data. Known values are: "local", "remote", + and "scratch". } ], "gpu_info": { @@ -150917,9 +154695,10 @@ class of Droplets created from this size. For example: Basic, General of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -151543,9 +155322,10 @@ def get(self, droplet_id: int, **kwargs: Any) -> JSON: of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -151588,9 +155368,9 @@ def get(self, droplet_id: int, **kwargs: Any) -> JSON: measure for the disk size. }, "type": "str" # Optional. The type of disk. All - Droplets contain a ``local`` disk. Additionally, GPU Droplets can - also have a ``scratch`` disk for non-persistent data. Known values - are: "local" and "scratch". + Droplets contain a ``local`` or ``remote`` disk. Additionally, GPU + Droplets can also have a ``scratch`` disk for non-persistent data. + Known values are: "local", "remote", and "scratch". } ], "gpu_info": { @@ -153116,9 +156896,10 @@ class of Droplets created from this size. For example: Basic, General The unit of measure for the disk size. }, "type": "str" # Optional. The type - of disk. All Droplets contain a ``local`` disk. Additionally, - GPU Droplets can also have a ``scratch`` disk for - non-persistent data. Known values are: "local" and "scratch". + of disk. All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk + for non-persistent data. Known values are: "local", "remote", + and "scratch". } ], "gpu_info": { @@ -153163,9 +156944,10 @@ class of Droplets created from this size. For example: Basic, General of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -167328,6 +171110,10 @@ def list_clusters( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -167640,6 +171426,10 @@ def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -167859,6 +171649,10 @@ def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -168111,6 +171905,10 @@ def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -168349,6 +172147,10 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -168568,6 +172370,10 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -168881,6 +172687,10 @@ def get_cluster(self, cluster_id: str, **kwargs: Any) -> JSON: "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -169123,6 +172933,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -169319,6 +173133,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -169583,6 +173401,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -169740,6 +173562,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -169936,6 +173762,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -195524,9 +199354,10 @@ def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -195598,9 +199429,10 @@ def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -195658,9 +199490,10 @@ def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -195947,9 +199780,10 @@ def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Required. :type body: JSON @@ -196014,9 +199848,10 @@ def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Required. :type body: IO[bytes] @@ -196072,9 +199907,10 @@ def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Is either a JSON type or a IO[bytes] type. Required. :type body: JSON or IO[bytes] @@ -209355,9 +213191,10 @@ def list(self, *, per_page: int = 20, page: int = 1, **kwargs: Any) -> JSON: of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index b9074baf..4392355f 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -208,6 +208,14 @@ def make_parent(responses: List[FakeResponse]) -> MagicMock: parent._client.format_url = lambda url, **_kwargs: ( url if str(url).startswith("http") else f"https://api.digitalocean.com{url}" ) + from pydo.operations import SessionsOperations + + parent.sessions = SessionsOperations( + parent._client, + MagicMock(), + MagicMock(), + MagicMock(), + ) return parent @@ -218,6 +226,14 @@ def make_async_parent(responses: List[AsyncFakeResponse]) -> MagicMock: parent._client.format_url = lambda url, **_kwargs: ( url if str(url).startswith("http") else f"https://api.digitalocean.com{url}" ) + from pydo.aio.operations import SessionsOperations + + parent.sessions = SessionsOperations( + parent._client, + MagicMock(), + MagicMock(), + MagicMock(), + ) return parent diff --git a/tests/gateway/test_action_gateway_client.py b/tests/gateway/test_action_gateway_client.py index 24bd6559..1756c1d4 100644 --- a/tests/gateway/test_action_gateway_client.py +++ b/tests/gateway/test_action_gateway_client.py @@ -51,6 +51,8 @@ def test_namespace_client_dir_is_gateway_focused(): surface = set(dir(client)) expected = { "sessions", + "sessions_api", + "connections", "provider", "base_url", "chat", @@ -59,9 +61,11 @@ def test_namespace_client_dir_is_gateway_focused(): "responses", "session", "toolbelts", + "tools", + "users", } assert expected <= surface - for attr in ("tools", "code", "handle_tool_calls", "droplets"): + for attr in ("code", "handle_tool_calls", "droplets"): assert attr not in surface @@ -74,7 +78,11 @@ def test_sessions_delegate_to_gateway(): client = ActionGatewayClient(token="dummy") assert client.sessions is client.gateway.sessions assert client.session is client.sessions + assert client.sessions_api is not client.sessions + assert client.connections is not None + assert client.tools is not None assert client.toolbelts is not None + assert client.users is not None assert client.provider is client.gateway.provider @@ -176,4 +184,8 @@ def test_async_namespace_mirrors_sync(): assert repr(client) == "" assert client.sessions is client.gateway.sessions assert client.session is client.sessions + assert client.sessions_api is not client.sessions + assert client.connections is not None + assert client.tools is not None assert client.toolbelts is not None + assert client.users is not None diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index d341c9cb..4931fef6 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -120,7 +120,7 @@ def test_mcp_transport_parses_sse_response(): def test_session_create_uses_public_api_and_actor_header(): parent = make_async_parent( [ - AsyncFakeResponse(201, session_create_response()), + AsyncFakeResponse(200, session_create_response()), AsyncFakeResponse(200, jsonrpc_result({"tools": []})), ] ) @@ -138,7 +138,7 @@ async def scenario(): session = _run(scenario()) create_request = parent._client._pipeline.calls[0].request - assert create_request.url.endswith("/v2/action-gateway/sessions") + assert create_request.url.endswith("/v2/sessions") assert json.loads(create_request.content) == { "actor_id": "actor-123", "name": "named", @@ -157,7 +157,7 @@ async def scenario(): def test_session_approve_posts_to_gateway(): parent = make_async_parent( [ - AsyncFakeResponse(201, session_create_response()), + AsyncFakeResponse(200, session_create_response()), AsyncFakeResponse(200, {"status": "approved"}), ] ) @@ -181,7 +181,7 @@ async def scenario(): def test_session_deny_posts_to_gateway(): parent = make_async_parent( [ - AsyncFakeResponse(201, session_create_response()), + AsyncFakeResponse(200, session_create_response()), AsyncFakeResponse(200, {"status": "denied"}), ] ) diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 6df94826..256f7632 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -72,16 +72,14 @@ def test_sessions_create_requires_actor_id(): ops.create("") -def test_sessions_create_404_has_route_diagnostic(): +def test_sessions_create_404_uses_generated_error_mapping(): response = FakeResponse( 404, {"id": "not_found", "message": "Your request could not be routed."}, ) - response.request = SimpleNamespace( - url="https://api.digitalocean.com/v2/action-gateway/sessions" - ) + response.request = SimpleNamespace(url="https://api.digitalocean.com/v2/sessions") ops = SessionsOperations(make_parent([response]), gateway_endpoint=TEST_GATEWAY_URL) - with pytest.raises(ResourceNotFoundError, match="session create returned 404"): + with pytest.raises(ResourceNotFoundError): ops.create("user-123") @@ -98,7 +96,7 @@ def test_sessions_create_posts_to_do_api_and_binds_returned_mcp_url(): create_req = parent._client._pipeline.calls[0].request assert create_req.method == "POST" - assert create_req.url.endswith("/v2/action-gateway/sessions") + assert create_req.url.endswith("/v2/sessions") body = json.loads(create_req.content) assert body["actor_id"] == "user-123" assert "end_user_id" not in body From 9a991e765fcf0dd99177ee42c0af1045dd00d015 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 29 Jul 2026 15:05:11 -0500 Subject: [PATCH 15/19] Use generated Action Gateway public routes --- src/pydo/aio/gateway/session.py | 2 +- src/pydo/gateway/__init__.py | 2 +- src/pydo/gateway/session.py | 2 +- src/pydo/operations/_operations.py | 40 ++++++++++----------- tests/gateway/test_action_gateway_client.py | 2 +- tests/gateway/test_async_gateway.py | 2 +- tests/gateway/test_session.py | 6 ++-- 7 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/pydo/aio/gateway/session.py b/src/pydo/aio/gateway/session.py index 5c940d76..dbaee9fa 100644 --- a/src/pydo/aio/gateway/session.py +++ b/src/pydo/aio/gateway/session.py @@ -100,7 +100,7 @@ def __repr__(self) -> str: # pragma: no cover class AsyncSessionsOperations: - """Create sessions through the generated async ``/v2/sessions`` operation.""" + """Create sessions through the generated async Action Gateway operation.""" def __init__( self, diff --git a/src/pydo/gateway/__init__.py b/src/pydo/gateway/__init__.py index 886cad70..e137bc2b 100644 --- a/src/pydo/gateway/__init__.py +++ b/src/pydo/gateway/__init__.py @@ -5,7 +5,7 @@ """Action Gateway API — hand-written; preserved across ``make generate``. Session-first surface: create a session on the DigitalOcean API -(generated ``POST /v2/sessions``), then discover/invoke tools and run code +(generated ``POST /v2/action-gateway/sessions``), then discover/invoke tools and run code through the API-returned MCP endpoint. Composio-style providers make session tools plug into pydo inference surfaces (chat completions, messages, responses). """ diff --git a/src/pydo/gateway/session.py b/src/pydo/gateway/session.py index 7016ed13..103e361e 100644 --- a/src/pydo/gateway/session.py +++ b/src/pydo/gateway/session.py @@ -140,7 +140,7 @@ def __repr__(self) -> str: # pragma: no cover - debug aid class SessionsOperations: - """Create sessions through the generated ``/v2/sessions`` operation.""" + """Create sessions through the generated Action Gateway operation.""" def __init__( self, diff --git a/src/pydo/operations/_operations.py b/src/pydo/operations/_operations.py index 8ade4266..d7451f57 100644 --- a/src/pydo/operations/_operations.py +++ b/src/pydo/operations/_operations.py @@ -64,7 +64,7 @@ def build_tools_list_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/tools" + _url = "/v2/action-gateway/tools" # Construct parameters if toolkit_id is not None: @@ -90,7 +90,7 @@ def build_tools_list_toolkits_request(**kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/tools/toolkits" + _url = "/v2/action-gateway/tools/toolkits" # Construct headers _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") @@ -104,7 +104,7 @@ def build_tools_list_providers_request(**kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/tools/providers" + _url = "/v2/action-gateway/tools/providers" # Construct headers _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") @@ -125,7 +125,7 @@ def build_tools_get_definition_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/tools/{name}/definition" + _url = "/v2/action-gateway/tools/{name}/definition" path_format_arguments = { "name": _SERIALIZER.url("name", name, "str"), } @@ -155,7 +155,7 @@ def build_toolbelts_list_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/toolbelts" + _url = "/v2/action-gateway/toolbelts" # Construct parameters if status is not None: @@ -184,7 +184,7 @@ def build_toolbelts_create_request(**kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/toolbelts" + _url = "/v2/action-gateway/toolbelts" # Construct headers if content_type is not None: @@ -205,7 +205,7 @@ def build_toolbelts_get_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/toolbelts/{name}" + _url = "/v2/action-gateway/toolbelts/{name}" path_format_arguments = { "name": _SERIALIZER.url( "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" @@ -234,7 +234,7 @@ def build_toolbelts_delete_request(name: str, **kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/toolbelts/{name}" + _url = "/v2/action-gateway/toolbelts/{name}" path_format_arguments = { "name": _SERIALIZER.url( "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" @@ -258,7 +258,7 @@ def build_toolbelts_add_tools_request(name: str, **kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/toolbelts/{name}/tools/add" + _url = "/v2/action-gateway/toolbelts/{name}/tools/add" path_format_arguments = { "name": _SERIALIZER.url( "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" @@ -286,7 +286,7 @@ def build_toolbelts_delete_tools_request(name: str, **kwargs: Any) -> HttpReques accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/toolbelts/{name}/tools/remove" + _url = "/v2/action-gateway/toolbelts/{name}/tools/remove" path_format_arguments = { "name": _SERIALIZER.url( "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" @@ -322,7 +322,7 @@ def build_connections_list_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/connections" + _url = "/v2/action-gateway/connections" # Construct parameters if provider is not None: @@ -361,7 +361,7 @@ def build_connections_create_request(**kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/connections" + _url = "/v2/action-gateway/connections" # Construct headers if content_type is not None: @@ -379,7 +379,7 @@ def build_connections_get_request(id: str, **kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/connections/{id}" + _url = "/v2/action-gateway/connections/{id}" path_format_arguments = { "id": _SERIALIZER.url("id", id, "str"), } @@ -401,7 +401,7 @@ def build_connections_update_request(id: str, **kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/connections/{id}" + _url = "/v2/action-gateway/connections/{id}" path_format_arguments = { "id": _SERIALIZER.url("id", id, "str"), } @@ -424,7 +424,7 @@ def build_connections_delete_request(id: str, **kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/connections/{id}" + _url = "/v2/action-gateway/connections/{id}" path_format_arguments = { "id": _SERIALIZER.url("id", id, "str"), } @@ -446,7 +446,7 @@ def build_users_list_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/users" + _url = "/v2/action-gateway/users" # Construct parameters if page is not None: @@ -470,7 +470,7 @@ def build_users_get_request(user_id: str, **kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/users/{user_id}" + _url = "/v2/action-gateway/users/{user_id}" path_format_arguments = { "user_id": _SERIALIZER.url("user_id", user_id, "str"), } @@ -496,7 +496,7 @@ def build_sessions_list_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/sessions" + _url = "/v2/action-gateway/sessions" # Construct parameters if end_user_id is not None: @@ -525,7 +525,7 @@ def build_sessions_create_request(**kwargs: Any) -> HttpRequest: accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/sessions" + _url = "/v2/action-gateway/sessions" # Construct headers if content_type is not None: @@ -543,7 +543,7 @@ def build_sessions_delete_request(session_urn: str, **kwargs: Any) -> HttpReques accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/v2/sessions/{session_urn}" + _url = "/v2/action-gateway/sessions/{session_urn}" path_format_arguments = { "session_urn": _SERIALIZER.url("session_urn", session_urn, "str"), } diff --git a/tests/gateway/test_action_gateway_client.py b/tests/gateway/test_action_gateway_client.py index 1756c1d4..4c369a6c 100644 --- a/tests/gateway/test_action_gateway_client.py +++ b/tests/gateway/test_action_gateway_client.py @@ -126,7 +126,7 @@ def run(self, request, **_kwargs): assert toolbelt.ref == "search-toolbelt@1" request = client._client._pipeline.calls[0] - assert request.url.endswith("/v2/toolbelts") + assert request.url.endswith("/v2/action-gateway/toolbelts") assert json.loads(request.content) == { "name": "search-toolbelt", "tools": ["exa_web_search"], diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index 4931fef6..9b121184 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -138,7 +138,7 @@ async def scenario(): session = _run(scenario()) create_request = parent._client._pipeline.calls[0].request - assert create_request.url.endswith("/v2/sessions") + assert create_request.url.endswith("/v2/action-gateway/sessions") assert json.loads(create_request.content) == { "actor_id": "actor-123", "name": "named", diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 256f7632..d9e36824 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -77,7 +77,9 @@ def test_sessions_create_404_uses_generated_error_mapping(): 404, {"id": "not_found", "message": "Your request could not be routed."}, ) - response.request = SimpleNamespace(url="https://api.digitalocean.com/v2/sessions") + response.request = SimpleNamespace( + url="https://api.digitalocean.com/v2/action-gateway/sessions" + ) ops = SessionsOperations(make_parent([response]), gateway_endpoint=TEST_GATEWAY_URL) with pytest.raises(ResourceNotFoundError): ops.create("user-123") @@ -96,7 +98,7 @@ def test_sessions_create_posts_to_do_api_and_binds_returned_mcp_url(): create_req = parent._client._pipeline.calls[0].request assert create_req.method == "POST" - assert create_req.url.endswith("/v2/sessions") + assert create_req.url.endswith("/v2/action-gateway/sessions") body = json.loads(create_req.content) assert body["actor_id"] == "user-123" assert "end_user_id" not in body From 43ce9a41d13146ebc4ab3db240930b7169f0c136 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 29 Jul 2026 15:14:06 -0500 Subject: [PATCH 16/19] Add generated Action Gateway API examples --- examples/gateway/create_toolbelt.py | 3 ++ examples/gateway/public_api.py | 76 ++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/examples/gateway/create_toolbelt.py b/examples/gateway/create_toolbelt.py index 3a30a24c..f9b08ca1 100644 --- a/examples/gateway/create_toolbelt.py +++ b/examples/gateway/create_toolbelt.py @@ -31,3 +31,6 @@ "search-toolbelt", body={"tools": ["exa_web_fetch"]}, ) + +# Delete the toolbelt when it is no longer needed. +# client.toolbelts.delete("search-toolbelt") diff --git a/examples/gateway/public_api.py b/examples/gateway/public_api.py index be5405f0..46b2bdea 100644 --- a/examples/gateway/public_api.py +++ b/examples/gateway/public_api.py @@ -1,7 +1,12 @@ -"""Use the OpenAPI-generated Action Gateway control-plane APIs. +"""Use every OpenAPI-generated Action Gateway resource. Required env: DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID + CONNECTION_ID enables get, update, and delete connection examples + SESSION_URN enables session deletion example """ import os @@ -9,12 +14,67 @@ from pydo.action_gateway import ActionGatewayClient client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +actor_id = os.environ.get("ACTOR_ID", "example-user") + +# Tools are read-only catalog resources. +print("Tools:", client.tools.list(toolkit_id="exa")) +print("Toolkits:", client.tools.list_toolkits()) +print("Providers:", client.tools.list_providers()) +print( + "Definition:", + client.tools.get_definition("exa_web_search", version="v1"), +) + +# Toolbelts support create, list, get, membership changes, and delete. +toolbelt = client.toolbelts.create( + body={"name": "search-toolbelt", "tools": ["exa_web_search"]} +) +print("Created toolbelt:", toolbelt) +print("Toolbelts:", client.toolbelts.list(status="active")) +print("Toolbelt:", client.toolbelts.get("search-toolbelt")) +client.toolbelts.add_tools( + "search-toolbelt", + body={"tools": ["exa_web_fetch"]}, +) +client.toolbelts.delete_tools( + "search-toolbelt", + body={"tools": ["exa_web_fetch"]}, +) + +# Connections support create, list, get, parameter updates, and delete. +connection = client.connections.create( + body={"provider": "github", "user_id": actor_id, "scopes": ["repo"]} +) +print("Created connection:", connection) +print("Connections:", client.connections.list(user_id=actor_id)) + +connection_id = os.environ.get("CONNECTION_ID") +if connection_id: + print("Connection:", client.connections.get(connection_id)) + client.connections.update( + connection_id, + body={"connection_parameters": {"site_url": "https://github.com"}}, + ) + client.connections.delete(connection_id) + +# Users are derived from their sessions and connections. +print("Users:", client.users.list()) +print("User:", client.users.get(actor_id)) + +# Sessions are generated too. The convenience session API delegates creation +# to this same generated resource and returns a session bound to response.mcpUrl. +print("Sessions:", client.sessions_api.list(end_user_id=actor_id)) +session = client.session.create( + actor_id=actor_id, + tools=["exa_web_search@v1"], + config={"preloadTools": ["exa_web_search@v1"]}, + permissions={"default_action": "ask"}, +) +print("Session MCP URL:", session.url) -print(client.tools.list()) -print(client.tools.list_toolkits()) -print(client.tools.list_providers()) -print(client.tools.get_definition("exa_web_search", version="v1")) +session_urn = os.environ.get("SESSION_URN") +if session_urn: + client.sessions_api.delete(session_urn) -print(client.connections.list(user_id="example-user")) -print(client.users.list()) -print(client.sessions_api.list(end_user_id="example-user")) +# Uncomment when the example toolbelt is no longer needed. +# client.toolbelts.delete("search-toolbelt") From 89f3111acf93a5d46eceeeb70797a533b524759a Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 29 Jul 2026 15:18:11 -0500 Subject: [PATCH 17/19] Fix Action Gateway test lint --- tests/gateway/conftest.py | 8 +++----- tests/gateway/test_session.py | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index 4392355f..134f8f36 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -14,8 +14,10 @@ from pydo.aio.gateway import AsyncGatewayResources from pydo.aio.gateway.custom_operations import AsyncRESTTransport +from pydo.aio.operations import SessionsOperations as AsyncSessionsOperations from pydo.custom_extensions import _BaseURLProxy from pydo.gateway import GatewayResources, RESTTransport +from pydo.operations import SessionsOperations TEST_SESSION_URN = "do:managed_agents_session:test-session" TEST_GATEWAY_URL = "https://actions.do-ai-test.run" @@ -208,8 +210,6 @@ def make_parent(responses: List[FakeResponse]) -> MagicMock: parent._client.format_url = lambda url, **_kwargs: ( url if str(url).startswith("http") else f"https://api.digitalocean.com{url}" ) - from pydo.operations import SessionsOperations - parent.sessions = SessionsOperations( parent._client, MagicMock(), @@ -226,9 +226,7 @@ def make_async_parent(responses: List[AsyncFakeResponse]) -> MagicMock: parent._client.format_url = lambda url, **_kwargs: ( url if str(url).startswith("http") else f"https://api.digitalocean.com{url}" ) - from pydo.aio.operations import SessionsOperations - - parent.sessions = SessionsOperations( + parent.sessions = AsyncSessionsOperations( parent._client, MagicMock(), MagicMock(), diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index d9e36824..c4678a0b 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -159,7 +159,7 @@ def test_sessions_create_sends_tool_selection_and_config(): body = json.loads(parent._client._pipeline.calls[0].request.content) assert body["tools"] == ["web_search@v1", "toolbelt:read-only@2"] assert body["config"] == {"preloadTools": ["web_search@v1"]} - assert session.selected_tools == [] + assert not session.selected_tools def test_session_approve_posts_to_gateway(): From dbca478a21f1c1d659bed7a16f30e1d88456ca62 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 29 Jul 2026 15:45:50 -0500 Subject: [PATCH 18/19] Test generated Action Gateway session delegation --- tests/gateway/test_async_gateway.py | 26 ++++++++++++++++++++++++++ tests/gateway/test_session.py | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py index 9b121184..f665f6bc 100644 --- a/tests/gateway/test_async_gateway.py +++ b/tests/gateway/test_async_gateway.py @@ -9,6 +9,7 @@ import asyncio import json +from unittest.mock import AsyncMock, MagicMock import pytest from azure.core.exceptions import HttpResponseError @@ -117,6 +118,31 @@ def test_mcp_transport_parses_sse_response(): assert _run(gateway.tools.list())[0].name == "action_search" +def test_session_create_delegates_to_generated_operation(): + parent = MagicMock() + parent.sessions.create = AsyncMock( + return_value=session_create_response(name="named") + ) + operations = AsyncSessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + session = _run( + operations.create( + "actor-123", + name="named", + tools=["web_search@v1"], + config={"preloadTools": ["web_search@v1"]}, + ) + ) + + parent.sessions.create.assert_awaited_once() + body = parent.sessions.create.await_args.kwargs["body"] + assert body["actor_id"] == "actor-123" + assert body["name"] == "named" + assert body["policy"]["defaultAction"] == "ask" + assert body["config"]["preloadTools"] == ["web_search@v1"] + assert session.name == "named" + + def test_session_create_uses_public_api_and_actor_header(): parent = make_async_parent( [ diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index c4678a0b..21b091a1 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -9,6 +9,7 @@ import json from types import SimpleNamespace +from unittest.mock import MagicMock import pytest from azure.core.exceptions import ResourceNotFoundError @@ -72,6 +73,27 @@ def test_sessions_create_requires_actor_id(): ops.create("") +def test_sessions_create_delegates_to_generated_operation(): + parent = MagicMock() + parent.sessions.create.return_value = session_create_response(name="named") + operations = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + session = operations.create( + "actor-123", + name="named", + tools=["web_search@v1"], + config={"preloadTools": ["web_search@v1"]}, + ) + + parent.sessions.create.assert_called_once() + body = parent.sessions.create.call_args.kwargs["body"] + assert body["actor_id"] == "actor-123" + assert body["name"] == "named" + assert body["tools"] == ["web_search@v1"] + assert body["config"] == {"preloadTools": ["web_search@v1"]} + assert session.name == "named" + + def test_sessions_create_404_uses_generated_error_mapping(): response = FakeResponse( 404, From 52b1e1e09699dbccea50e5e091fbcee7f0dfbbd3 Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Wed, 29 Jul 2026 16:36:38 -0500 Subject: [PATCH 19/19] Handle flat toolbelt create responses --- src/pydo/action_gateway/__init__.py | 8 +- src/pydo/action_gateway/aio/__init__.py | 8 +- src/pydo/gateway/custom_models.py | 28 +++++++ tests/gateway/test_action_gateway_client.py | 86 ++++++++++++++++++++- 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/pydo/action_gateway/__init__.py b/src/pydo/action_gateway/__init__.py index 70a8e6c4..6821fc0b 100644 --- a/src/pydo/action_gateway/__init__.py +++ b/src/pydo/action_gateway/__init__.py @@ -25,6 +25,7 @@ ) messages = session.handle_tool_calls(response) """ + from __future__ import annotations from typing import List, Optional @@ -118,8 +119,11 @@ def create_toolbelt(self, name: str, tools, **kwargs) -> Toolbelt: if isinstance(tools, (str, bytes)): raise TypeError("tools must be an iterable of tool names") body = {"name": name, "tools": list(tools), **kwargs} - response = self.toolbelts.create(body=body) - return Toolbelt(response["toolbelt"]) + response = self.toolbelts.create( + body=body, + cls=Toolbelt.validate_create_response, + ) + return Toolbelt.from_response(response) @property def base_url(self) -> Optional[str]: diff --git a/src/pydo/action_gateway/aio/__init__.py b/src/pydo/action_gateway/aio/__init__.py index 3115f095..bc657090 100644 --- a/src/pydo/action_gateway/aio/__init__.py +++ b/src/pydo/action_gateway/aio/__init__.py @@ -8,6 +8,7 @@ Asynchronous twin of :class:`pydo.action_gateway.Client`. Same surface, ``await``-friendly. See :mod:`pydo.action_gateway` for usage details. """ + from __future__ import annotations from typing import List, Optional @@ -96,8 +97,11 @@ async def create_toolbelt(self, name: str, tools, **kwargs) -> Toolbelt: if isinstance(tools, (str, bytes)): raise TypeError("tools must be an iterable of tool names") body = {"name": name, "tools": list(tools), **kwargs} - response = await self.toolbelts.create(body=body) - return Toolbelt(response["toolbelt"]) + response = await self.toolbelts.create( + body=body, + cls=Toolbelt.validate_create_response, + ) + return Toolbelt.from_response(response) @property def base_url(self) -> Optional[str]: diff --git a/src/pydo/gateway/custom_models.py b/src/pydo/gateway/custom_models.py index 0315d430..65b731f6 100644 --- a/src/pydo/gateway/custom_models.py +++ b/src/pydo/gateway/custom_models.py @@ -8,6 +8,8 @@ from typing import Any, Dict, List, Optional +from azure.core.exceptions import HttpResponseError, ResourceExistsError + # Meta-tool names exposed on the gateway's ``/mcp/meta`` endpoint. META_SEARCH = "action_search" META_INVOKE = "action_invoke" @@ -136,6 +138,32 @@ def __repr__(self) -> str: # pragma: no cover - debug aid class Toolbelt(dict): """A generated toolbelt response with a concise ``ref`` alias.""" + @classmethod + def from_response(cls, response: Any) -> "Toolbelt": + """Accept the documented envelope and legacy flat API response.""" + if not isinstance(response, dict): + raise GatewayProtocolError( + f"unexpected toolbelt create response: {response!r}" + ) + data = response.get("toolbelt", response) + if not isinstance(data, dict) or not data.get("reference"): + raise GatewayProtocolError( + f"toolbelt create response missing toolbelt reference: {response!r}" + ) + return cls(data) + + @staticmethod + def validate_create_response( + pipeline_response: Any, response: Any, _headers: Any + ) -> Any: + """Raise for generated error responses before returning the body.""" + http_response = pipeline_response.http_response + if http_response.status_code == 409: + raise ResourceExistsError(response=http_response) + if http_response.status_code != 200: + raise HttpResponseError(response=http_response) + return response + def __getattr__(self, name: str) -> Any: if name == "ref": return self.get("reference") diff --git a/tests/gateway/test_action_gateway_client.py b/tests/gateway/test_action_gateway_client.py index 4c369a6c..c32c3ae9 100644 --- a/tests/gateway/test_action_gateway_client.py +++ b/tests/gateway/test_action_gateway_client.py @@ -8,15 +8,22 @@ from __future__ import annotations import json +from unittest.mock import AsyncMock import pytest +from azure.core.exceptions import ResourceExistsError import pydo import pydo.action_gateway import pydo.aio from pydo.action_gateway import ActionGatewayClient from pydo.gateway.transport import _META_TOOL_DEFINITIONS -from pydo.gateway import ChatCompletionsProvider, MessagesProvider +from pydo.gateway import ( + ChatCompletionsProvider, + GatewayProtocolError, + MessagesProvider, + Toolbelt, +) from .conftest import ( FakeResponse, @@ -133,6 +140,56 @@ def run(self, request, **_kwargs): } +def test_create_toolbelt_accepts_flat_api_response(monkeypatch): + client = ActionGatewayClient(token="dummy") + monkeypatch.setattr( + client.toolbelts, + "create", + lambda **_kwargs: { + "name": "search-toolbelt", + "version": "1", + "reference": "search-toolbelt@1", + "tools": ["exa_web_search"], + }, + ) + + toolbelt = client.create_toolbelt( + name="search-toolbelt", + tools=["exa_web_search"], + ) + + assert toolbelt.ref == "search-toolbelt@1" + + +def test_create_toolbelt_raises_generated_conflict(monkeypatch): + client = ActionGatewayClient(token="dummy") + response = FakeResponse( + 409, + { + "id": "conflict", + "message": "A toolbelt with this name already exists.", + }, + ) + + class Pipeline: + def run(self, request, **_kwargs): + response.request = request + return type("R", (), {"http_response": response})() + + monkeypatch.setattr(client._client, "_pipeline", Pipeline()) + + with pytest.raises(ResourceExistsError): + client.create_toolbelt( + name="search-toolbelt", + tools=["exa_web_search"], + ) + + +def test_toolbelt_rejects_unexpected_create_response(): + with pytest.raises(GatewayProtocolError, match="missing toolbelt reference"): + Toolbelt.from_response({"name": "search-toolbelt"}) + + def test_create_toolbelt_rejects_string_tools(): client = ActionGatewayClient(token="dummy") with pytest.raises(TypeError, match="iterable of tool names"): @@ -189,3 +246,30 @@ def test_async_namespace_mirrors_sync(): assert client.tools is not None assert client.toolbelts is not None assert client.users is not None + + +@pytest.mark.skipif(not _HAS_AIO, reason="aiohttp extra not installed") +def test_async_create_toolbelt_accepts_flat_api_response(monkeypatch): + from pydo.action_gateway.aio import ActionGatewayClient as AsyncActionGatewayClient + + client = AsyncActionGatewayClient(token="dummy") + create = AsyncMock( + return_value={ + "name": "search-toolbelt", + "version": "1", + "reference": "search-toolbelt@1", + "tools": ["exa_web_search"], + } + ) + monkeypatch.setattr(client.toolbelts, "create", create) + + async def scenario(): + return await client.create_toolbelt( + name="search-toolbelt", + tools=["exa_web_search"], + ) + + import asyncio + + toolbelt = asyncio.run(scenario()) + assert toolbelt.ref == "search-toolbelt@1"