Skip to main content

Python port of @openrouter/agent: OpenRouter tool orchestration, streaming, state, and format compatibility.

Project description

openrouter-agent-sdk

openrouter-agent-sdk is a Python agent toolkit for OpenRouter. It ports the public behavior of @openrouter/agent into an async-first Python package: Responses API calls, client and server tools, streaming consumption, multi-turn state, approval and human-in-the-loop gates, tool context, stop conditions, and Claude/OpenAI Chat format compatibility.

This package builds on the official openrouter Python SDK. It does not reimplement HTTP, auth, retries, or model schemas; call_model sends requests through client.beta.responses.send_async, the same Responses API surface used by the TypeScript package.

This package is a port. @openrouter/agent (TypeScript) is the reference spec; this repo is kept in sync automatically. See PORTING.md.

Install

pip install openrouter-agent-sdk
# or
uv add openrouter-agent-sdk

The distribution is openrouter-agent-sdk; the import is openrouter_agent:

from openrouter_agent import call_model, tool

Requires Python 3.10+ (the openrouter SDK dropped 3.9 at 1.0.0).

Quick Start

import asyncio
from pydantic import BaseModel
from openrouter_agent import OpenRouter, call_model, tool

class WeatherInput(BaseModel):
    location: str

class WeatherOutput(BaseModel):
    temperature: int
    condition: str
    location: str

async def main() -> None:
    client = OpenRouter(api_key="YOUR_API_KEY")

    weather_tool = tool(
        name="get_weather",
        description="Get the current weather for a location",
        input_schema=WeatherInput,
        output_schema=WeatherOutput,
        execute=lambda params, ctx: WeatherOutput(
            temperature=72,
            condition="sunny",
            location=params.location,
        ),
    )

    result = call_model(
        client,
        {
            "model": "openai/gpt-4o",
            "input": "What is the weather in San Francisco?",
            "tools": [weather_tool],
        },
    )

    print(await result.get_text())

asyncio.run(main())

Streaming

call_model returns a ModelResult. The result can be consumed multiple ways; each method works from the same completed run so concurrent consumers do not steal events from each other.

result = call_model(client, {"model": model, "input": prompt, "tools": tools})

text = await result.get_text()
response = await result.get_response()

async for delta in result.get_text_stream():
    print(delta, end="")

async for delta in result.get_reasoning_stream():
    print(delta, end="")

async for event in result.get_tool_stream():
    print(event)

async for call in result.get_tool_calls_stream():
    print(call.name, call.arguments)

Tool Variants

Regular tools execute automatically when the model emits a matching function_call.

search = tool(
    name="search",
    input_schema=SearchInput,
    output_schema=SearchOutput,
    execute=run_search,
)

Generator tools yield progress events and a final output. Python async generators cannot return a final value, so the final yield is treated as the output and earlier yields are preliminary events.

analysis = tool(
    name="analyze",
    input_schema=AnalysisInput,
    event_schema=ProgressEvent,
    output_schema=AnalysisOutput,
    execute=analyze_stream,
)

Manual tools are advertised to the model but not auto-executed.

confirm = tool(name="confirm_action", input_schema=ConfirmInput, execute=False)

HITL tools use on_tool_called; returning a value auto-resolves, returning None pauses with pending tool calls in state.

approve_wire = tool(
    name="approve_wire",
    input_schema=WireInput,
    output_schema=WireDecision,
    on_tool_called=ask_human,
)

Server tools pass through to OpenRouter unchanged.

from openrouter_agent import server_tool

web = server_tool({"type": "web_search_2025_08_26", "max_results": 5})

Approval, Context, and State

Use require_approval on a tool or require_approval on the request to pause sensitive calls before execution. Approval resume requires a state accessor with async load() and save() methods.

Manual tools (execute=False, no on_tool_called) pause the loop with status "awaiting_client_tools" when the model calls them, instead of silently dropping the call. Read the unresolved calls via get_pending_tool_calls() / get_state(), execute them yourself, and continue by calling call_model again with function_call_output items in input.

For durable cross-process storage, serialize state with serialize_conversation_state / deserialize_conversation_state rather than storing raw dataclass fields. The wire format is versioned (CONVERSATION_STATE_VERSION); a version mismatch raises UnsupportedStateVersionError and malformed JSON raises InvalidStateError, so a store can never silently misinterpret a future shape.

Tool context is kept outside the model transcript. Provide a context mapping with per-tool keys and optional shared state. Tool execution receives ctx["local"], ctx["shared"], ctx["set_context"], and ctx["set_shared_context"].

result = call_model(
    client,
    {
        "model": model,
        "input": "List all users",
        "tools": [query_db],
        "context": {"query_db": {"connection_string": "postgres://localhost/app"}},
        "stop_when": step_count_is(5),
    },
)

Lifecycle Hooks

Pass a HooksManager (or an inline {hook_name: [HookEntry(...)]} dict of built-in hooks) via hooks= to observe or intervene in a run: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, PermissionRequest, SessionStart, SessionEnd, and PostModelCall. Handlers receive a validated payload dict and a LifecycleHookContext (session_id, hook_name, cancel_event).

from openrouter_agent import HookEntry, HookName, HooksManager

hooks = HooksManager()
hooks.on(HookName.PreToolUse.value, HookEntry(handler=lambda payload, ctx: None, matcher="delete_file"))
hooks.on(HookName.SessionEnd.value, HookEntry(handler=lambda payload, ctx: print(payload["total_usage"])))

result = call_model(client, {"model": model, "input": prompt, "tools": tools, "hooks": hooks})

SessionStart fires once per run with a config summary; SessionEnd fires once with aggregated total_usage (summed across every PostModelCall) and is guaranteed to fire — and any pending async hook work drained — even when a no-tools stream raises. PreToolUse can block a call ({"block": "reason"}) or mutate its input ({"mutated_input": {...}}); PermissionRequest can pre-empt the approval gate with {"decision": "allow" | "deny" | "ask_user"}; Stop can force the loop to keep going past a stop_when hit with {"force_resume": True, "append_prompt": "..."}. A HooksManager instance is safe to share across concurrent call_model runs — session identity is threaded per emit, not stored as manager-level mutable state.

Stop Conditions

The built-ins mirror the TypeScript package and OR together when provided as a list:

  • step_count_is(n)
  • has_tool_call(name)
  • max_tokens_used(n)
  • max_cost(dollars)
  • finish_reason_is(reason)

When a stop condition fires while the model is still emitting tool calls, call_model makes one more turn with tool_choice="none" by default (tools stay in the request so the prompt-cache prefix survives) so the run ends with a natural-language answer. allow_final_response tunes this: True or omitted appends DEFAULT_FINAL_RESPONSE_DIRECTIVE as a user message, a non-empty string replaces the wording, "" appends nothing, and False disables the extra turn entirely.

Format Compatibility

Use from_claude_messages / to_claude_message for Anthropic-style messages and from_chat_messages / to_chat_message for OpenAI Chat-style messages. Content that cannot be represented directly is carried as unsupported_content instead of being silently discarded.

Development

uv sync --frozen --all-extras

uv run pytest tests/unit -q              # deterministic suite
uv run pytest tests/unit --cov           # with coverage
uv run mypy src tests                    # types, tests included
uv run ruff check . && uv run ruff format --check .

tests/e2e/ runs against the live OpenRouter API and skips cleanly without OPENROUTER_API_KEY:

OPENROUTER_API_KEY=sk-or-... uv run pytest tests/e2e -q

Tests share fixtures from tests/_fixtures.pymake_response, function_call_item, text_response, tool_call_response, QueuedClient, MemoryStateAccessor. Use them instead of hand-rolling a fake client: make_response populates every field the real Responses API returns, so a stub cannot be more permissive than production.

CI runs the suite on Python 3.10, 3.11, and 3.13, type-checks src and tests, enforces a coverage floor, and verifies the built wheel imports in isolation. Because this package is a port, tests are held to upstream behavior — see the Test Parity section of .upstreamer/upstreamer.md and PORTING.md.

Parity Notes

This is a faithful Python port of the @openrouter/agent public surface, with Python-native names (call_model, server_tool, get_text_stream) and Pydantic v2 schemas in place of Zod. Runtime behavior is preserved where the Python SDK exposes matching Responses API types. Static type inference is necessarily looser than TypeScript conditional types; the package ships py.typed, Protocol/dataclass aliases, and clear runtime validation rather than pretending to reproduce TypeScript tuple inference exactly.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

openrouter_agent_sdk-0.8.0.tar.gz (80.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

openrouter_agent_sdk-0.8.0-py3-none-any.whl (60.2 kB view details)

Uploaded Python 3

File details

Details for the file openrouter_agent_sdk-0.8.0.tar.gz.

File metadata

  • Download URL: openrouter_agent_sdk-0.8.0.tar.gz
  • Upload date:
  • Size: 80.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for openrouter_agent_sdk-0.8.0.tar.gz
Algorithm Hash digest
SHA256 30ab61778da163ab825af932df387f8bc6de5ba98eb5935ad5453b6f6e8bbd80
MD5 3dc53656c7baa5f3e0fa12bdb17a7f04
BLAKE2b-256 e81d141b9313d80436ff28e408c814816924ca9894d37e4073b4e0e54177e112

See more details on using hashes here.

Provenance

The following attestation bundles were made for openrouter_agent_sdk-0.8.0.tar.gz:

Publisher: publish.yaml on OpenRouterTeam/python-agent

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file openrouter_agent_sdk-0.8.0-py3-none-any.whl.

File metadata

File hashes

Hashes for openrouter_agent_sdk-0.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9a0c46b0787386a3b4dc3ce246030e3c05f427943aab0137b8689f22d67df0f7
MD5 0ded703461924bac4ca51e048f5ce6d5
BLAKE2b-256 0098decdd50e70388b4d974a6a4ee4745ec9085220e27143c3a8c09ea5585fe7

See more details on using hashes here.

Provenance

The following attestation bundles were made for openrouter_agent_sdk-0.8.0-py3-none-any.whl:

Publisher: publish.yaml on OpenRouterTeam/python-agent

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page