From f7c8a4366b94297edd4a6464b52a6a639c151eaf Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sun, 8 Mar 2026 13:29:36 -0300 Subject: [PATCH] Add OpenAI Responses API vm_run integration example --- README.md | 2 + docs/integrations.md | 93 +++++++++++++++++++++++++++ examples/openai_responses_vm_run.py | 98 +++++++++++++++++++++++++++++ tests/test_openai_example.py | 70 +++++++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 docs/integrations.md create mode 100644 examples/openai_responses_vm_run.py create mode 100644 tests/test_openai_example.py diff --git a/README.md b/README.md index 44b3181..f0325f9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ It also ships an MCP server so LLM clients can use the same VM runtime through t - Install: [docs/install.md](/home/thales/projects/personal/pyro/docs/install.md) - Host requirements: [docs/host-requirements.md](/home/thales/projects/personal/pyro/docs/host-requirements.md) +- Integration targets: [docs/integrations.md](/home/thales/projects/personal/pyro/docs/integrations.md) - Public contract: [docs/public-contract.md](/home/thales/projects/personal/pyro/docs/public-contract.md) - Troubleshooting: [docs/troubleshooting.md](/home/thales/projects/personal/pyro/docs/troubleshooting.md) @@ -142,6 +143,7 @@ pyro demo ollama -v - Python one-shot SDK example: [examples/python_run.py](/home/thales/projects/personal/pyro/examples/python_run.py) - Python lifecycle example: [examples/python_lifecycle.py](/home/thales/projects/personal/pyro/examples/python_lifecycle.py) - MCP client config example: [examples/mcp_client_config.md](/home/thales/projects/personal/pyro/examples/mcp_client_config.md) +- OpenAI Responses API example: [examples/openai_responses_vm_run.py](/home/thales/projects/personal/pyro/examples/openai_responses_vm_run.py) - Agent-ready `vm_run` example: [examples/agent_vm_run.py](/home/thales/projects/personal/pyro/examples/agent_vm_run.py) ## Python SDK diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..55efb64 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,93 @@ +# Integration Targets + +These are the main ways to integrate `pyro-mcp` into an LLM application. + +## Recommended Default + +Use `vm_run` first. + +That keeps the model-facing contract small: + +- one tool +- one command +- one ephemeral VM +- automatic cleanup + +Only move to lifecycle tools when the agent truly needs VM state across multiple calls. + +## OpenAI Responses API + +Best when: + +- your agent already uses OpenAI models directly +- you want a normal tool-calling loop instead of MCP transport +- you want the smallest amount of integration code + +Recommended surface: + +- `vm_run` + +Canonical example: + +- [examples/openai_responses_vm_run.py](/home/thales/projects/personal/pyro/examples/openai_responses_vm_run.py) + +## MCP Clients + +Best when: + +- your host application already supports MCP +- you want `pyro` to run as an external stdio server +- you want tool schemas to be discovered directly from the server + +Recommended entrypoint: + +- `pyro mcp serve` + +Starter config: + +- [examples/mcp_client_config.md](/home/thales/projects/personal/pyro/examples/mcp_client_config.md) + +## Direct Python SDK + +Best when: + +- your application owns orchestration itself +- you do not need MCP transport +- you want direct access to `Pyro` + +Recommended default: + +- `Pyro.run_in_vm(...)` + +Examples: + +- [examples/python_run.py](/home/thales/projects/personal/pyro/examples/python_run.py) +- [examples/python_lifecycle.py](/home/thales/projects/personal/pyro/examples/python_lifecycle.py) + +## Agent Framework Wrappers + +Examples: + +- LangChain tools +- PydanticAI tools +- custom in-house orchestration layers + +Best when: + +- you already have an application framework that expects a Python callable tool +- you want to wrap `vm_run` behind framework-specific abstractions + +Recommended pattern: + +- keep the framework wrapper thin +- map framework tool input directly onto `vm_run` +- avoid exposing lifecycle tools unless the framework truly needs them + +## Selection Rule + +Choose the narrowest integration that matches the host environment: + +1. OpenAI Responses API if you want a direct provider tool loop. +2. MCP if your host already speaks MCP. +3. Python SDK if you own orchestration and do not need transport. +4. Framework wrappers only as thin adapters over the same `vm_run` contract. diff --git a/examples/openai_responses_vm_run.py b/examples/openai_responses_vm_run.py new file mode 100644 index 0000000..da59782 --- /dev/null +++ b/examples/openai_responses_vm_run.py @@ -0,0 +1,98 @@ +"""Canonical OpenAI Responses API integration centered on vm_run. + +Requirements: +- `pip install openai` or `uv add openai` +- `OPENAI_API_KEY` + +This example keeps the model-facing contract intentionally small: one `vm_run` +tool that creates an ephemeral VM, runs one command, and cleans up. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from pyro_mcp import Pyro + +DEFAULT_MODEL = "gpt-5" + +OPENAI_VM_RUN_TOOL: dict[str, Any] = { + "type": "function", + "name": "vm_run", + "description": "Run one command in an ephemeral Firecracker VM and clean it up.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "profile": {"type": "string"}, + "command": {"type": "string"}, + "vcpu_count": {"type": "integer"}, + "mem_mib": {"type": "integer"}, + "timeout_seconds": {"type": "integer"}, + "ttl_seconds": {"type": "integer"}, + "network": {"type": "boolean"}, + }, + "required": ["profile", "command", "vcpu_count", "mem_mib"], + "additionalProperties": False, + }, +} + + +def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]: + pyro = Pyro() + return pyro.run_in_vm( + profile=str(arguments["profile"]), + command=str(arguments["command"]), + vcpu_count=int(arguments["vcpu_count"]), + mem_mib=int(arguments["mem_mib"]), + timeout_seconds=int(arguments.get("timeout_seconds", 30)), + ttl_seconds=int(arguments.get("ttl_seconds", 600)), + network=bool(arguments.get("network", False)), + ) + + +def run_openai_vm_run_example(*, prompt: str, model: str = DEFAULT_MODEL) -> str: + from openai import OpenAI # type: ignore[import-not-found] + + client = OpenAI() + input_items: list[dict[str, Any]] = [{"role": "user", "content": prompt}] + + while True: + response = client.responses.create( + model=model, + input=input_items, + tools=[OPENAI_VM_RUN_TOOL], + ) + input_items.extend(response.output) + + tool_calls = [item for item in response.output if item.type == "function_call"] + if not tool_calls: + return str(response.output_text) + + for tool_call in tool_calls: + if tool_call.name != "vm_run": + raise RuntimeError(f"unexpected tool requested: {tool_call.name}") + result = call_vm_run(json.loads(tool_call.arguments)) + input_items.append( + { + "type": "function_call_output", + "call_id": tool_call.call_id, + "output": json.dumps(result, sort_keys=True), + } + ) + + +def main() -> None: + model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL) + prompt = ( + "Use the vm_run tool to run `git --version` in an ephemeral VM. " + "Use the debian-git profile with 1 vCPU and 1024 MiB of memory. " + "Do not use networking for this request." + ) + print(run_openai_vm_run_example(prompt=prompt, model=model)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_openai_example.py b/tests/test_openai_example.py new file mode 100644 index 0000000..fb5bc46 --- /dev/null +++ b/tests/test_openai_example.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any, cast + +import pytest + + +def _load_openai_example_module() -> ModuleType: + path = Path("examples/openai_responses_vm_run.py") + spec = importlib.util.spec_from_file_location("openai_responses_vm_run", path) + if spec is None or spec.loader is None: + raise AssertionError("failed to load OpenAI example module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_openai_example_tool_targets_vm_run() -> None: + module = _load_openai_example_module() + assert module.OPENAI_VM_RUN_TOOL["name"] == "vm_run" + assert module.OPENAI_VM_RUN_TOOL["type"] == "function" + assert module.OPENAI_VM_RUN_TOOL["strict"] is True + + +def test_openai_example_runs_function_call_loop(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_openai_example_module() + tool_call = SimpleNamespace( + type="function_call", + name="vm_run", + call_id="call_123", + arguments=( + '{"profile":"debian-git","command":"git --version",' + '"vcpu_count":1,"mem_mib":1024}' + ), + ) + responses = [ + SimpleNamespace(output=[tool_call], output_text=""), + SimpleNamespace(output=[], output_text="git version 2.40.1"), + ] + calls: list[dict[str, Any]] = [] + + class FakeResponses: + def create(self, **kwargs: Any) -> Any: + calls.append(kwargs) + return responses.pop(0) + + class FakeOpenAI: + def __init__(self) -> None: + self.responses = FakeResponses() + + fake_openai_module = ModuleType("openai") + cast(Any, fake_openai_module).OpenAI = FakeOpenAI + monkeypatch.setitem(sys.modules, "openai", fake_openai_module) + monkeypatch.setattr( + module, + "call_vm_run", + lambda arguments: {"exit_code": 0, "stdout": f"ran {arguments['command']}"}, + ) + + result = module.run_openai_vm_run_example(prompt="run git --version") + + assert result == "git version 2.40.1" + assert calls[0]["tools"][0]["name"] == "vm_run" + second_input = calls[1]["input"] + assert second_input[-1]["type"] == "function_call_output" + assert second_input[-1]["call_id"] == "call_123"