From 49d41e9454679198be4d5a39cfcedd44df5976ca Mon Sep 17 00:00:00 2001 From: shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:28:11 +0530 Subject: [PATCH 1/3] feat: session.run, wait_until_ready and opencode.testing fake server - codegen: HELPER_BASES lets a generated resource class inherit from a hand-written base in src/opencode/lib; SessionResource uses it for run() - client.session.run (sync/async): create or reuse a session, prompt, wait until idle, return RunResult (messages, text, outcome, error, usage); interrupts the session on KeyboardInterrupt, cancellation or errors - client.wait_until_ready: poll agents and MCP servers after boot - transport= on both clients, Opencode.fake() - opencode.testing.FakeServer: in-memory httpx transport for user tests --- README.md | 49 +++ codegen/render.py | 1 + src/opencode/_base_client.py | 14 +- src/opencode/_client.py | 69 +++- src/opencode/lib/__init__.py | 5 + src/opencode/lib/_ready.py | 91 ++++++ src/opencode/lib/_session.py | 223 +++++++++++++ src/opencode/resources/session.py | 5 +- src/opencode/testing.py | 508 ++++++++++++++++++++++++++++++ tests/test_helpers.py | 252 +++++++++++++++ tests/test_live.py | 23 +- 11 files changed, 1233 insertions(+), 7 deletions(-) create mode 100644 src/opencode/lib/__init__.py create mode 100644 src/opencode/lib/_ready.py create mode 100644 src/opencode/lib/_session.py create mode 100644 src/opencode/testing.py create mode 100644 tests/test_helpers.py diff --git a/README.md b/README.md index c22ee30..60d7068 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,55 @@ client.session.get("ses_...", timeout=5.0) # per call The default timeout is 10 minutes with a 10 second connect timeout. +## Running an agent headless + +`client.session.run` does the usual "prompt an agent and wait" round trip in one call: it creates a session (or +reuses `session_id`), sends the prompt, blocks until the session is idle, and returns the new messages with the +final text and usage. `client.wait_until_ready` waits until agents and MCP servers have loaded, which happens +asynchronously after the server boots. Both have async versions on `AsyncOpencode`. + +```python +from opencode import Opencode +from opencode.types import ModelRef, PromptInputFileAttachment + +with Opencode("http://127.0.0.1:4096", password="...") as client: + client.wait_until_ready(agents=["reviewer"], mcp_servers=["github"], timeout=60) + result = client.session.run( + "Review the attached diff", + agent="reviewer", + model=ModelRef(provider_id="anthropic", id="claude-sonnet-5-5"), # set it with the agent + files=[PromptInputFileAttachment(uri="data:text/plain;base64,...", name="diff.patch")], + timeout=600, + ) + print(result.outcome, result.text) + print(result.cost, result.tokens.input, result.tokens.output) # this run; result.session has the totals +``` + +If the wait ends early (Ctrl-C, task cancellation, `timeout`, a connection error), `run` interrupts the session +on the server before re-raising, so the agent does not keep running unattended. + +## Testing your code + +`opencode.testing.FakeServer` is an in-process fake of the server: an `httpx` transport that keeps sessions, +messages and events in memory and answers with the same payloads as the real server. No process, no model. + +```python +from opencode import Opencode +from opencode.testing import FakeServer + + +def test_summarize() -> None: + fake = FakeServer(reply=lambda prompt: f"summary of {prompt!r}") + client = Opencode.fake(fake) # same as Opencode(transport=fake) + + assert client.session.run("README").text == "summary of 'README'" + assert fake.prompts == ["README"] +``` + +`fake.fail(method, path_regex, status=..., exception=...)` injects errors, `fake.add_agent(..., after_polls=n)` +and `fake.set_mcp_status(...)` simulate slow boots, and `fake.requests` records every request. Routes the fake +does not implement answer `404`. + ## Running a server `OpencodeServer` runs `opencode serve` as a child process on a free local port with a random password: diff --git a/codegen/render.py b/codegen/render.py index 6eb622b..8e3d9b6 100644 --- a/codegen/render.py +++ b/codegen/render.py @@ -382,6 +382,7 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i # The mixins subclass the resource base classes and cover endpoints the generator cannot express (WebSockets). MIXINS: dict[tuple[str, ...], tuple[str, str, str]] = { ("pty",): ("_pty", "PtyAttachMixin", "AsyncPtyAttachMixin"), + ("session",): ("lib._session", "SessionHelpers", "AsyncSessionHelpers"), ("experimental", "persistentPty"): ("_pty", "PersistentPtyAttachMixin", "AsyncPersistentPtyAttachMixin"), } diff --git a/src/opencode/_base_client.py b/src/opencode/_base_client.py index 43d8efb..ed3ce92 100644 --- a/src/opencode/_base_client.py +++ b/src/opencode/_base_client.py @@ -307,6 +307,7 @@ def __init__( default_headers: Mapping[str, str] | None, default_query: Mapping[str, object] | None, http_client: httpx.Client | None, + transport: httpx.BaseTransport | None = None, ) -> None: super().__init__( base_url=base_url, @@ -319,7 +320,11 @@ def __init__( default_query=default_query, ) self._owns_http = http_client is None - self._http = http_client or httpx.Client(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True) + if http_client is not None and transport is not None: + raise ValueError("Pass either http_client or transport, not both") + self._http = http_client or httpx.Client( + limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True, transport=transport + ) def close(self) -> None: """Closes the connection pool, unless it was passed in by the caller.""" @@ -421,6 +426,7 @@ def __init__( default_headers: Mapping[str, str] | None, default_query: Mapping[str, object] | None, http_client: httpx.AsyncClient | None, + transport: httpx.AsyncBaseTransport | None = None, ) -> None: super().__init__( base_url=base_url, @@ -433,7 +439,11 @@ def __init__( default_query=default_query, ) self._owns_http = http_client is None - self._http = http_client or httpx.AsyncClient(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True) + if http_client is not None and transport is not None: + raise ValueError("Pass either http_client or transport, not both") + self._http = http_client or httpx.AsyncClient( + limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True, transport=transport + ) async def close(self) -> None: """Closes the connection pool, unless it was passed in by the caller.""" diff --git a/src/opencode/_client.py b/src/opencode/_client.py index fdd5d7a..0e0d46c 100644 --- a/src/opencode/_client.py +++ b/src/opencode/_client.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import cached_property -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from typing_extensions import Self @@ -14,12 +14,17 @@ if TYPE_CHECKING: import os - from collections.abc import Mapping + from collections.abc import Iterable, Mapping import httpx + from .testing import FakeServer + __all__ = ["AsyncOpencode", "Opencode"] +FAKE_BASE_URL = "http://opencode.fake" +"""Base URL of clients made by ``Opencode.fake()``.""" + class Opencode(SyncAPIClient, SyncResources): """Synchronous client for an OpenCode server. @@ -38,6 +43,8 @@ class Opencode(SyncAPIClient, SyncResources): default_query: Query parameters sent with every request. http_client: A preconfigured ``httpx.Client`` (proxies, custom transports, event hooks). The caller keeps ownership and must close it. + transport: An ``httpx`` transport for the client's own connection pool, such as + :class:`opencode.testing.FakeServer`. Cannot be combined with ``http_client``. Example:: @@ -60,6 +67,7 @@ def __init__( default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, http_client: httpx.Client | None = None, + transport: httpx.BaseTransport | None = None, ) -> None: super().__init__( base_url=base_url, @@ -71,6 +79,7 @@ def __init__( default_headers=default_headers, default_query=default_query, http_client=http_client, + transport=transport, ) @cached_property @@ -83,6 +92,33 @@ def rpc(self) -> TypedRpcResource: """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access.""" return TypedRpcResource(self) + @classmethod + def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self: + """Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests. + + Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor. + """ + from .testing import FakeServer + + kwargs.setdefault("password", "") + return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs) + + def wait_until_ready( + self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60 + ) -> None: + """Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are + connected. + + Agents load asynchronously after the server starts, so call this before creating sessions on a freshly + booted server. Connection errors and 503s while the server boots count as not ready yet. + + Raises: + TimeoutError: Something was still missing after ``timeout`` seconds. The message names it. + """ + from .lib._ready import wait_until_ready + + wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout) + def with_options( self, *, @@ -138,6 +174,7 @@ def __init__( default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, http_client: httpx.AsyncClient | None = None, + transport: httpx.AsyncBaseTransport | None = None, ) -> None: super().__init__( base_url=base_url, @@ -149,6 +186,7 @@ def __init__( default_headers=default_headers, default_query=default_query, http_client=http_client, + transport=transport, ) @cached_property @@ -161,6 +199,33 @@ def rpc(self) -> AsyncTypedRpcResource: """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access.""" return AsyncTypedRpcResource(self) + @classmethod + def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self: + """Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests. + + Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor. + """ + from .testing import FakeServer + + kwargs.setdefault("password", "") + return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs) + + async def wait_until_ready( + self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60 + ) -> None: + """Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are + connected. + + Agents load asynchronously after the server starts, so call this before creating sessions on a freshly + booted server. Connection errors and 503s while the server boots count as not ready yet. + + Raises: + TimeoutError: Something was still missing after ``timeout`` seconds. The message names it. + """ + from .lib._ready import async_wait_until_ready + + await async_wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout) + def with_options( self, *, diff --git a/src/opencode/lib/__init__.py b/src/opencode/lib/__init__.py new file mode 100644 index 0000000..4c455c3 --- /dev/null +++ b/src/opencode/lib/__init__.py @@ -0,0 +1,5 @@ +"""Hand-written helpers layered over the generated client.""" + +from ._session import RunResult + +__all__ = ["RunResult"] diff --git a/src/opencode/lib/_ready.py b/src/opencode/lib/_ready.py new file mode 100644 index 0000000..2035b7c --- /dev/null +++ b/src/opencode/lib/_ready.py @@ -0,0 +1,91 @@ +"""``client.wait_until_ready``: poll until agents and MCP servers have loaded. + +OpenCode loads agents and connects MCP servers in the background after it starts listening, so a freshly booted +server can answer requests before the agent a caller needs exists. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import anyio + +from .._exceptions import APIConnectionError, ServiceUnavailableError + +if TYPE_CHECKING: + from collections.abc import Iterable + + from .._client import AsyncOpencode, Opencode + from ..types import AgentListResponse, McpListResponse + +__all__ = ["POLL_INTERVAL", "async_wait_until_ready", "wait_until_ready"] + +POLL_INTERVAL = 0.25 +"""Seconds between polls.""" + +_TRANSIENT = (APIConnectionError, ServiceUnavailableError) + + +def _missing( + agents: frozenset[str], + mcp_servers: frozenset[str], + agent_list: AgentListResponse | None, + mcp_list: McpListResponse | None, +) -> list[str]: + missing: list[str] = [] + if agents: + present = {agent.id for agent in agent_list.data} if agent_list is not None else set[str]() + missing += [f"agent {name!r}" for name in sorted(agents - present)] + if mcp_servers: + connected = ( + {server.name for server in mcp_list.data if server.status.status == "connected"} + if mcp_list is not None + else set[str]() + ) + missing += [f"MCP server {name!r}" for name in sorted(mcp_servers - connected)] + return missing + + +def wait_until_ready( + client: Opencode, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60 +) -> None: + wanted_agents, wanted_mcp = frozenset(agents), frozenset(mcp_servers) + deadline = time.monotonic() + timeout + while True: + agent_list = mcp_list = None + missing = ["server"] + try: + client.server.info() + agent_list = client.agent.list() if wanted_agents else None + mcp_list = client.mcp.list() if wanted_mcp else None + missing = _missing(wanted_agents, wanted_mcp, agent_list, mcp_list) + except _TRANSIENT: + pass + if not missing: + return + if time.monotonic() >= deadline: + raise TimeoutError(f"OpenCode not ready after {timeout}s; still waiting for {', '.join(missing)}") + time.sleep(POLL_INTERVAL) + + +async def async_wait_until_ready( + client: AsyncOpencode, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60 +) -> None: + wanted_agents, wanted_mcp = frozenset(agents), frozenset(mcp_servers) + deadline = time.monotonic() + timeout + while True: + agent_list = mcp_list = None + missing = ["server"] + try: + await client.server.info() + agent_list = await client.agent.list() if wanted_agents else None + mcp_list = await client.mcp.list() if wanted_mcp else None + missing = _missing(wanted_agents, wanted_mcp, agent_list, mcp_list) + except _TRANSIENT: + pass + if not missing: + return + if time.monotonic() >= deadline: + raise TimeoutError(f"OpenCode not ready after {timeout}s; still waiting for {', '.join(missing)}") + await anyio.sleep(POLL_INTERVAL) diff --git a/src/opencode/lib/_session.py b/src/opencode/lib/_session.py new file mode 100644 index 0000000..c6602f8 --- /dev/null +++ b/src/opencode/lib/_session.py @@ -0,0 +1,223 @@ +"""``client.session.run``: create or reuse a session, prompt it, wait until idle, and collect the result. + +The generated ``SessionResource`` / ``AsyncSessionResource`` inherit from the classes here (see +``codegen/render.py``, ``HELPER_BASES``), so these methods sit next to the generated ones. +""" + +from __future__ import annotations + +import contextlib +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, cast + +import anyio + +from .._resource import AsyncAPIResource, SyncAPIResource +from .._types import NOT_GIVEN, NotGiven, Timeout +from ..types import ( + AgentID, + ModelRef, + PromptInputFileAttachment, + SessionID, + SessionInfo, + SessionMessageAssistant, + SessionMessageAssistantText, + SessionMessageIdle, + SessionMessageInfo, + SessionMessageUser, + SessionStructuredError, + TokenUsageInfo, + TokenUsageInfoCache, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from ..resources.session import AsyncSessionResource, SessionResource + +__all__ = ["AsyncSessionHelpers", "RunResult", "SessionHelpers"] + + +@dataclass +class RunResult: + """What :meth:`SessionHelpers.run` returns.""" + + session: SessionInfo + """The session as it was after the run. ``session.cost`` and ``session.tokens`` cover the whole session.""" + messages: list[SessionMessageInfo] = field(default_factory=list[SessionMessageInfo]) + """Messages this run added, oldest first, starting with the prompt.""" + + @property + def assistant_messages(self) -> list[SessionMessageAssistant]: + """The assistant messages of this run, oldest first.""" + return [message for message in self.messages if isinstance(message, SessionMessageAssistant)] + + @property + def text(self) -> str: + """Text of the last assistant message, or ``""`` when the agent produced none.""" + for message in reversed(self.assistant_messages): + parts = [item.text for item in message.content if isinstance(item, SessionMessageAssistantText)] + if parts: + return "".join(parts) + return "" + + @property + def outcome(self) -> str | None: + """``succeeded``, ``failed`` or ``interrupted``, from the run's idle marker or the session.""" + for message in reversed(self.messages): + if isinstance(message, SessionMessageIdle): + return message.outcome + return self.session.outcome + + @property + def error(self) -> SessionStructuredError | None: + """Why the run failed, from its idle marker or last assistant message, or ``None``.""" + for message in reversed(self.messages): + if isinstance(message, (SessionMessageIdle, SessionMessageAssistant)) and message.error is not None: + return message.error + return None + + @property + def cost(self) -> float: + """Cost of this run in USD, summed over its assistant messages.""" + return sum(message.cost or 0.0 for message in self.assistant_messages) + + @property + def tokens(self) -> TokenUsageInfo: + """Token usage of this run, summed over its assistant messages.""" + total = TokenUsageInfo(input=0, output=0, reasoning=0, cache=TokenUsageInfoCache(read=0, write=0)) + for message in self.assistant_messages: + usage = message.tokens + if usage is None: + continue + total.input += usage.input + total.output += usage.output + total.reasoning += usage.reasoning + total.cache.read += usage.cache.read + total.cache.write += usage.cache.write + return total + + +def _new_messages(newest_first: list[SessionMessageInfo], marker: str | None) -> list[SessionMessageInfo]: + collected: list[SessionMessageInfo] = [] + for message in newest_first: + if marker is not None and message.id == marker: + break + collected.append(message) + collected.reverse() + # Drop anything that was already queued before our prompt. + for index, message in enumerate(collected): + if isinstance(message, SessionMessageUser): + return collected[index:] + return collected + + +class SessionHelpers(SyncAPIResource): + """Hand-written helpers on ``client.session``.""" + + def run( + self, + text: str, + *, + agent: AgentID | None = None, + model: ModelRef | None = None, + files: Sequence[PromptInputFileAttachment] | NotGiven = NOT_GIVEN, + session_id: SessionID | None = None, + timeout: Timeout = None, + ) -> RunResult: + """Runs one prompt to completion and returns what the agent produced. + + Creates a session with ``agent`` and ``model`` (or reuses ``session_id``, switching its agent and model when + given), sends ``text``, blocks until the session is idle, and reads back the new messages and the session's + usage. + + If the wait is interrupted (``KeyboardInterrupt``, a timeout, or any other error), the session is + interrupted on the server before the error propagates, so no agent keeps running unattended. + + Args: + text: The prompt. + agent: Agent for a new session, or to switch an existing one to. + model: Model for a new session, or to switch an existing one to. Set it with ``agent`` so the session + does not start on another model. + files: File attachments; images can be ``data:`` URLs. + session_id: Continue this session instead of creating one. + timeout: Seconds to wait for the session to become idle. ``None`` waits forever. + + Example:: + + result = client.session.run("Summarize README.md", agent="build") + print(result.text, result.cost) + """ + resource = cast("SessionResource", self) + if session_id is None: + session = resource.create(agent=agent or NOT_GIVEN, model=model or NOT_GIVEN) + session_id = session.id + marker = None + else: + if agent is not None: + resource.switch_agent(session_id, agent=agent) + if model is not None: + resource.switch_model(session_id, model=model) + latest = self._client.message.list(session_id, order="desc", limit=1).data + marker = latest[0].id if latest else None + + resource.prompt(session_id, text=text, files=files) + try: + resource.wait(session_id, timeout=timeout) + except BaseException: + with contextlib.suppress(Exception): + resource.interrupt(session_id) + raise + + newest_first: list[SessionMessageInfo] = [] + for message in self._client.message.list(session_id, order="desc"): + if marker is not None and message.id == marker: + break + newest_first.append(message) + return RunResult(session=resource.get(session_id), messages=_new_messages(newest_first, marker)) + + +class AsyncSessionHelpers(AsyncAPIResource): + """Hand-written helpers on ``client.session`` of :class:`opencode.AsyncOpencode`.""" + + async def run( + self, + text: str, + *, + agent: AgentID | None = None, + model: ModelRef | None = None, + files: Sequence[PromptInputFileAttachment] | NotGiven = NOT_GIVEN, + session_id: SessionID | None = None, + timeout: Timeout = None, + ) -> RunResult: + """Runs one prompt to completion. See :meth:`SessionHelpers.run`. + + Cancelling the task interrupts the session on the server before the cancellation propagates. + """ + resource = cast("AsyncSessionResource", self) + if session_id is None: + session = await resource.create(agent=agent or NOT_GIVEN, model=model or NOT_GIVEN) + session_id = session.id + marker = None + else: + if agent is not None: + await resource.switch_agent(session_id, agent=agent) + if model is not None: + await resource.switch_model(session_id, model=model) + latest = (await self._client.message.list(session_id, order="desc", limit=1)).data + marker = latest[0].id if latest else None + + await resource.prompt(session_id, text=text, files=files) + try: + await resource.wait(session_id, timeout=timeout) + except BaseException: + with anyio.CancelScope(shield=True), contextlib.suppress(Exception): + await resource.interrupt(session_id) + raise + + newest_first: list[SessionMessageInfo] = [] + async for message in self._client.message.list(session_id, order="desc"): + if marker is not None and message.id == marker: + break + newest_first.append(message) + return RunResult(session=await resource.get(session_id), messages=_new_messages(newest_first, marker)) diff --git a/src/opencode/resources/session.py b/src/opencode/resources/session.py index 47c463b..5112e8c 100644 --- a/src/opencode/resources/session.py +++ b/src/opencode/resources/session.py @@ -16,6 +16,7 @@ from .._resource import AsyncAPIResource, SyncAPIResource from .._streaming import AsyncStream, Stream from .._types import NOT_GIVEN, Body, Headers, NotGiven, OpenEnum, Query, Timeout +from ..lib._session import AsyncSessionHelpers, SessionHelpers from ..types import ( AbsolutePath, AgentID, @@ -651,7 +652,7 @@ def cancel( ) -class SessionResource(SyncAPIResource): +class SessionResource(SessionHelpers): """Operations under ``client.session`` (OpenCode ``session``).""" @cached_property @@ -2403,7 +2404,7 @@ async def cancel( ) -class AsyncSessionResource(AsyncAPIResource): +class AsyncSessionResource(AsyncSessionHelpers): """Operations under ``client.session`` (OpenCode ``session``).""" @cached_property diff --git a/src/opencode/testing.py b/src/opencode/testing.py new file mode 100644 index 0000000..ac96261 --- /dev/null +++ b/src/opencode/testing.py @@ -0,0 +1,508 @@ +"""An in-process fake OpenCode server for your own tests. + +:class:`FakeServer` is an ``httpx`` transport that answers like an OpenCode server, keeping sessions, messages and +events in memory. Plug it into a client and test code that uses the SDK without starting ``opencode serve`` or +calling a model:: + + from opencode import Opencode + from opencode.testing import FakeServer + + fake = FakeServer(reply="Done.") + client = Opencode.fake(fake) # or Opencode(transport=fake) + + result = client.session.run("Fix the bug", agent="build") + assert result.text == "Done." + assert fake.prompts == ["Fix the bug"] + +It covers sessions (create, get, list, remove, switch agent or model), prompts, waiting, interrupts, messages, +agents, MCP servers, server info and the event stream. Every payload is built from the generated models, so it +has the shape the real server sends. Other routes answer ``404``. Use :meth:`FakeServer.fail` to make a route fail. +""" + +from __future__ import annotations + +import itertools +import json +import os +import re +import time +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from typing import Any + +import httpx + +from ._models import BaseModel +from .types import ( + AgentInfo, + AgentListResponse, + LocationPublicRef, + McpListResponse, + McpServer, + ModelRef, + ServerConnectedEvent, + ServerInfo, + SessionCreatedEvent, + SessionIdleEvent, + SessionInboxUser, + SessionInfo, + SessionInterruptResponse, + SessionMessageAssistant, + SessionMessageAssistantText, + SessionMessageIdle, + SessionMessageUser, + TokenUsageInfo, +) + +__all__ = ["DEFAULT_MODEL", "FakeServer"] + +DEFAULT_MODEL = ModelRef(id="fake-model", provider_id="fake") +"""Model reported for sessions created without one.""" + +Reply = str | Callable[[str], str] +"""Assistant reply: fixed text, or a function from the prompt text to the reply.""" + + +@dataclass +class _Failure: + method: str + path: re.Pattern[str] + status: int + body: object + exception: BaseException | None + times: int | None + + +class FakeServer(httpx.MockTransport): + """An in-memory OpenCode server, usable as the ``transport`` of :class:`opencode.Opencode` and + :class:`opencode.AsyncOpencode`. + + Args: + reply: What the assistant answers to each prompt: text, or a function of the prompt text. + agents: Agent ids :meth:`opencode.Opencode.agent.list` reports. Add more later with :meth:`add_agent`. + mcp_servers: MCP server names mapped to their status (``connected``, ``pending``, ``failed``, ...). + directory: Project directory reported as every session's location. + cost: Cost in USD reported for each assistant reply. + tokens: Input and output tokens reported for each assistant reply. + + Attributes: + sessions: Sessions by id. + messages: Messages of each session, oldest first. + events: Events the stream replays after ``server.connected``. + requests: Every request received, in order. + """ + + def __init__( + self, + *, + reply: Reply = "OK", + agents: Iterable[str] = ("build", "plan"), + mcp_servers: Mapping[str, str] | None = None, + directory: str = "/fake/project", + cost: float = 0.001, + tokens: tuple[int, int] = (10, 5), + ) -> None: + super().__init__(self._handle) + self.reply: Reply = reply + self.directory = directory + self.cost = cost + self.tokens = tokens + self.sessions: dict[str, SessionInfo] = {} + self.messages: dict[str, list[BaseModel]] = {} + self.inbox: dict[str, list[SessionInboxUser]] = {} + self.events: list[BaseModel] = [] + self.requests: list[httpx.Request] = [] + self._agents: list[tuple[str, int]] = [(agent, 0) for agent in agents] + self._mcp: dict[str, tuple[str, int]] = {name: (status, 0) for name, status in (mcp_servers or {}).items()} + self._polls = {"agent": 0, "mcp": 0} + self._failures: list[_Failure] = [] + self._ids = itertools.count(1) + + # -- configuration -------------------------------------------------------------------------------------- + + def add_agent(self, agent: str, *, after_polls: int = 0) -> None: + """Lists ``agent`` once ``GET /api/agent`` has been called ``after_polls`` more times, like an agent that + loads after boot.""" + self._agents.append((agent, self._polls["agent"] + after_polls)) + + def set_mcp_status(self, name: str, status: str = "connected", *, after_polls: int = 0) -> None: + """Reports MCP server ``name`` with ``status`` once ``GET /api/mcp`` has been called ``after_polls`` more + times.""" + self._mcp[name] = (status, self._polls["mcp"] + after_polls) + + def fail( + self, + method: str, + path: str, + *, + status: int = 500, + body: object = None, + exception: BaseException | None = None, + times: int | None = 1, + ) -> None: + """Makes matching requests fail. + + Args: + method: HTTP method, such as ``POST``. + path: Regular expression matched against the whole request path, such as ``/api/session/[^/]+/prompt``. + status: Status of the error response. + body: JSON error body. Defaults to an untagged body, so the error class follows ``status``. + exception: Raise this from the transport instead of answering, for example ``httpx.ConnectError(...)`` + or ``KeyboardInterrupt()``. + times: How many requests fail; ``None`` for all of them. + """ + if body is None: + body = {"message": f"Injected failure ({status})"} + self._failures.append(_Failure(method.upper(), re.compile(path), status, body, exception, times)) + + @property + def prompts(self) -> list[str]: + """Text of every prompt received, in order.""" + texts: list[str] = [] + for request in self.requests: + if request.method == "POST" and request.url.path.endswith("/prompt"): + texts.append(json.loads(request.content)["text"]) + return texts + + # -- helpers -------------------------------------------------------------------------------------------- + + def _id(self, prefix: str) -> str: + return f"{prefix}_{next(self._ids):026d}" + + @staticmethod + def _now() -> float: + return float(time.time_ns() // 1_000_000) + + @property + def _location(self) -> LocationPublicRef: + return LocationPublicRef(directory=self.directory) + + def _session(self, session_id: str) -> SessionInfo: + try: + return self.sessions[session_id] + except KeyError: + raise _ErrorResponse( + 404, + { + "_tag": "SessionNotFoundError", + "sessionID": session_id, + "message": f"Session not found: {session_id}", + }, + ) from None + + @staticmethod + def _body(request: httpx.Request) -> dict[str, Any]: + if not request.content: + return {} + try: + payload = json.loads(request.content) + except ValueError: + raise _invalid("Request body is not JSON") from None + if not isinstance(payload, dict): + raise _invalid("Request body must be an object") + return dict(payload) # pyright: ignore[reportUnknownArgumentType] + + def _record(self, event: BaseModel) -> None: + self.events.append(event) + + def _run(self, session: SessionInfo, text: str) -> None: + """Answers one prompt synchronously: assistant reply, idle marker, usage, events.""" + reply = self.reply(text) if callable(self.reply) else self.reply + now = self._now() + input_tokens, output_tokens = self.tokens + usage = {"input": input_tokens, "output": output_tokens, "reasoning": 0, "cache": {"read": 0, "write": 0}} + self.messages[session.id] += [ + SessionMessageAssistant.model_validate( + { + "id": self._id("msg"), + "type": "assistant", + "time": {"created": now, "completed": now}, + "agent": session.agent or "build", + "model": (session.model or DEFAULT_MODEL).to_dict(), + "content": [SessionMessageAssistantText(type="text", text=reply).to_dict()], + "finish": "stop", + "cost": self.cost, + "tokens": usage, + } + ), + SessionMessageIdle.model_validate( + {"id": self._id("msg"), "type": "idle", "time": {"created": now}, "outcome": "succeeded"} + ), + ] + session.cost += self.cost + session.tokens = TokenUsageInfo.model_validate( + { + "input": session.tokens.input + input_tokens, + "output": session.tokens.output + output_tokens, + "reasoning": session.tokens.reasoning, + "cache": session.tokens.cache.to_dict(), + } + ) + session.outcome = "succeeded" + session.time.updated = session.time.idle = now + self._record( + SessionIdleEvent.model_validate( + {"id": self._id("evt"), "created": now, "type": "session.idle", "data": {"sessionID": session.id}} + ) + ) + + # -- routes --------------------------------------------------------------------------------------------- + + def _server_info(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + info = ServerInfo.model_validate( + { + "version": "fake", + "pid": os.getpid(), + "urls": [str(request.url.copy_with(path="/"))], + "paths": {"tmp": "/tmp"}, + } + ) + return _json(info) + + def _agent_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + self._polls["agent"] += 1 + visible = [agent for agent, after in self._agents if self._polls["agent"] > after] + agents = [ + AgentInfo.model_validate( + { + "id": agent, + "name": agent, + "mode": "primary", + "hidden": False, + "request": {"settings": {}, "headers": {}, "body": {}}, + "permissions": [], + } + ) + for agent in dict.fromkeys(visible) + ] + return _json(AgentListResponse(location=self._location, data=agents)) + + def _mcp_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + self._polls["mcp"] += 1 + servers = [ + McpServer.model_validate( + {"name": name, "status": _mcp_status(status if self._polls["mcp"] > after else "pending")} + ) + for name, (status, after) in self._mcp.items() + ] + return _json(McpListResponse(location=self._location, data=servers)) + + def _session_create(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + body = self._body(request) + now = self._now() + session_id = body.get("id") or self._id("ses") + if session_id in self.sessions: + raise _ErrorResponse(409, {"_tag": "ConflictError", "message": f"Session already exists: {session_id}"}) + session = SessionInfo.model_validate( + { + "id": session_id, + "parentID": body.get("parentID"), + "projectID": "prj_fake", + "agent": body.get("agent"), + "model": body.get("model") or DEFAULT_MODEL.to_dict(), + "cost": 0, + "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, + "time": {"created": now, "updated": now}, + "title": body.get("title"), + "metadata": body.get("metadata"), + "location": self._location.to_dict(), + } + ) + self.sessions[session.id] = session + self.messages[session.id] = [] + self.inbox[session.id] = [] + self._record( + SessionCreatedEvent.model_validate( + { + "id": self._id("evt"), + "created": now, + "type": "session.created", + "durable": {"aggregate_id": session.id, "seq": 0, "version": 1}, + "data": { + "sessionID": session.id, + "projectID": session.project_id, + "location": {"directory": self.directory}, + "slug": session.id, + "title": session.title, + "agent": session.agent, + "version": "fake", + }, + } + ) + ) + return _json({"data": session.to_dict()}) + + def _session_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + sessions = list(self.sessions.values()) + return _page(request, [session.to_dict() for session in sessions], newest_first_default=True) + + def _session_get(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + return _json({"data": self._session(match["id"]).to_dict()}) + + def _session_remove(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + self._session(match["id"]) + del self.sessions[match["id"]] + self.messages.pop(match["id"], None) + self.inbox.pop(match["id"], None) + return httpx.Response(204) + + def _session_agent(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + session = self._session(match["id"]) + agent = self._body(request).get("agent") + if not isinstance(agent, str): + raise _invalid("agent is required", field="agent") + session.agent = agent + return httpx.Response(204) + + def _session_model(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + session = self._session(match["id"]) + session.model = ModelRef.model_validate(self._body(request).get("model")) + return httpx.Response(204) + + def _session_prompt(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + session = self._session(match["id"]) + body = self._body(request) + text = body.get("text") + if not isinstance(text, str): + raise _invalid("text is required", field="text") + now = self._now() + message_id = body.get("id") or self._id("msg") + payload = {key: body[key] for key in ("text", "files", "agents", "skills", "metadata") if key in body} + queued = SessionInboxUser.model_validate( + { + "id": message_id, + "sessionID": session.id, + "time": {"created": now}, + "type": "user", + "payload": payload, + "delivery": body.get("delivery") or "queue", + } + ) + if body.get("resume") is False: + self.inbox[session.id].append(queued) + else: + self.messages[session.id].append( + SessionMessageUser.model_validate( + { + "id": message_id, + "type": "user", + "time": {"created": now}, + "text": text, + "metadata": body.get("metadata"), + } + ) + ) + self._run(session, text) + return _json({"data": queued.to_dict()}) + + def _session_wait(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + self._session(match["id"]) + return httpx.Response(204) + + def _session_interrupt(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + self._session(match["id"]) + return _json(SessionInterruptResponse(interrupted=False)) + + def _message_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + self._session(match["id"]) + wanted = request.url.params.get("type") + items = [ + m.to_dict() for m in self.messages[match["id"]] if wanted is None or getattr(m, "type", None) == wanted + ] + return _page(request, items, newest_first_default=False) + + def _event_stream(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response: + connected = ServerConnectedEvent.model_validate({"id": self._id("evt"), "type": "server.connected", "data": {}}) + chunks = [f"data: {json.dumps(event.to_dict())}\n\n" for event in [connected, *self.events]] + return httpx.Response(200, headers={"content-type": "text/event-stream"}, content="".join(chunks).encode()) + + _ROUTES: tuple[tuple[str, str, Callable[..., httpx.Response]], ...] = ( + ("GET", r"/api/info", _server_info), + ("GET", r"/api/agent", _agent_list), + ("GET", r"/api/mcp", _mcp_list), + ("GET", r"/api/event", _event_stream), + ("POST", r"/api/session", _session_create), + ("GET", r"/api/session", _session_list), + ("GET", r"/api/session/(?P[^/]+)", _session_get), + ("DELETE", r"/api/session/(?P[^/]+)", _session_remove), + ("POST", r"/api/session/(?P[^/]+)/agent", _session_agent), + ("POST", r"/api/session/(?P[^/]+)/model", _session_model), + ("POST", r"/api/session/(?P[^/]+)/prompt", _session_prompt), + ("POST", r"/api/session/(?P[^/]+)/interrupt", _session_interrupt), + ("GET", r"/api/session/(?P[^/]+)/message", _message_list), + ("POST", r"/api/experimental/session/(?P[^/]+)/wait", _session_wait), + ) + + def _handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path + for failure in self._failures: + if failure.method == request.method and failure.path.fullmatch(path) and failure.times != 0: + if failure.times is not None: + failure.times -= 1 + if failure.exception is not None: + raise failure.exception + return httpx.Response(failure.status, json=failure.body) + for method, pattern, handler in self._ROUTES: + match = re.fullmatch(pattern, path) + if match and method == request.method: + try: + return handler(self, request, match) + except _ErrorResponse as error: + return httpx.Response(error.status, json=error.body) + return httpx.Response( + 404, json={"_tag": "NotFoundError", "message": f"FakeServer does not implement {request.method} {path}"} + ) + + +class _ErrorResponse(Exception): + def __init__(self, status: int, body: dict[str, object]) -> None: + super().__init__(body.get("message")) + self.status = status + self.body = body + + +def _mcp_status(status: str) -> dict[str, object]: + if status == "failed": + return {"status": status, "error": "Fake MCP server failed to start"} + return {"status": status} + + +def _invalid(message: str, *, field: str | None = None) -> _ErrorResponse: + return _ErrorResponse(400, {"_tag": "InvalidRequestError", "message": message, "field": field}) + + +def _json(payload: BaseModel | dict[str, Any]) -> httpx.Response: + return httpx.Response(200, json=payload.to_dict() if isinstance(payload, BaseModel) else payload) + + +def _page(request: httpx.Request, items: list[dict[str, Any]], *, newest_first_default: bool) -> httpx.Response: + """Cursor pagination over ``items`` (oldest first). Cursors encode the order and offset, like the server's.""" + params = request.url.params + cursor = params.get("cursor") + if cursor is not None: + if "order" in params: + return httpx.Response( + 400, json={"_tag": "InvalidRequestError", "message": "order cannot be combined with cursor"} + ) + order, _, offset_text = cursor.partition(":") + if order not in ("asc", "desc") or not offset_text.isdigit(): + return httpx.Response(400, json={"_tag": "InvalidCursorError", "message": f"Invalid cursor: {cursor}"}) + offset = int(offset_text) + else: + order = params.get("order") or ("desc" if newest_first_default else "asc") + offset = 0 + limit = int(params.get("limit") or 50) + ordered = list(reversed(items)) if order == "desc" else items + data = ordered[offset : offset + limit] + end = offset + len(data) + return httpx.Response( + 200, + json={ + "data": data, + "cursor": { + "previous": f"{order}:{max(offset - limit, 0)}" if offset > 0 else None, + "next": f"{order}:{end}" if end < len(ordered) else None, + }, + }, + ) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..879d22f --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,252 @@ +"""Tests for the hand-written helpers (``session.run``, ``wait_until_ready``) and the fake server.""" + +from __future__ import annotations + +import anyio +import httpx +import pytest +from typing_extensions import override + +from opencode import ( + AsyncOpencode, + InvalidRequestError, + NotFoundError, + Opencode, + ServiceUnavailableError, + SessionNotFoundError, +) +from opencode.lib import RunResult +from opencode.testing import FakeServer +from opencode.types import ModelRef, ServerConnectedEvent, SessionCreatedEvent, SessionIdleEvent + + +@pytest.fixture(autouse=True) +def _fast_polls(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("opencode.lib._ready.POLL_INTERVAL", 0.0) + + +def _paths(fake: FakeServer) -> list[str]: + return [f"{request.method} {request.url.path}" for request in fake.requests] + + +# -- session.run ---------------------------------------------------------------------------------------------- + + +def test_run_creates_session_and_returns_result() -> None: + fake = FakeServer(reply=lambda text: f"echo: {text}", cost=0.5, tokens=(100, 20)) + client = Opencode.fake(fake) + model = ModelRef(id="claude", provider_id="anthropic") + + result = client.session.run("hello", agent="build", model=model) + + assert isinstance(result, RunResult) + assert result.text == "echo: hello" + assert result.outcome == "succeeded" + assert [message.type for message in result.messages] == ["user", "assistant", "idle"] + assert result.cost == 0.5 + assert (result.tokens.input, result.tokens.output) == (100, 20) + assert result.session.agent == "build" + assert result.session.model == model + assert result.session.cost == 0.5 + assert fake.prompts == ["hello"] + + +def test_run_reuses_session_and_only_returns_new_messages() -> None: + fake = FakeServer(reply="ok") + client = Opencode.fake(fake) + first = client.session.run("one") + + second = client.session.run("two", session_id=first.session.id, agent="plan") + + assert [message.type for message in second.messages] == ["user", "assistant", "idle"] + assert second.messages[0].text == "two" # type: ignore[union-attr] + assert second.cost == pytest.approx(fake.cost) + assert second.session.cost == pytest.approx(2 * fake.cost) + assert second.session.agent == "plan" + assert len(fake.sessions) == 1 + + +def test_run_follows_message_pages() -> None: + fake = FakeServer() + client = Opencode.fake(fake) + session = client.session.run("first").session + for index in range(30): + client.session.prompt(session.id, text=f"filler {index}") + + result = client.session.run("last", session_id=session.id) + + assert result.messages[0].text == "last" # type: ignore[union-attr] + assert len(result.messages) == 3 + + +def test_run_without_assistant_text() -> None: + client = Opencode.fake(FakeServer(reply="")) + assert client.session.run("quiet").text == "" + + +def test_run_interrupts_when_wait_fails() -> None: + fake = FakeServer() + fake.fail("POST", r"/api/experimental/session/.+/wait", status=503, times=None) + client = Opencode.fake(fake, max_retries=0) + + with pytest.raises(ServiceUnavailableError): + client.session.run("long task") + + assert _paths(fake)[-1].endswith("/interrupt") + + +def test_run_interrupts_on_keyboard_interrupt() -> None: + fake = FakeServer() + fake.fail("POST", r"/api/experimental/session/.+/wait", exception=KeyboardInterrupt()) + client = Opencode.fake(fake) + + with pytest.raises(KeyboardInterrupt): + client.session.run("long task") + + assert _paths(fake)[-1].endswith("/interrupt") + + +def test_run_unknown_session() -> None: + client = Opencode.fake() + with pytest.raises(SessionNotFoundError) as caught: + client.session.run("hi", session_id="ses_missing") + assert caught.value.error.session_id == "ses_missing" + + +@pytest.mark.anyio +async def test_async_run() -> None: + fake = FakeServer(reply="async ok") + async with AsyncOpencode.fake(fake) as client: + result = await client.session.run("hello", agent="build") + again = await client.session.run("more", session_id=result.session.id) + assert result.text == "async ok" + assert [message.type for message in again.messages] == ["user", "assistant", "idle"] + + +@pytest.mark.anyio +async def test_async_run_interrupts_on_cancel() -> None: + fake = FakeServer() + entered = anyio.Event() + + class Blocking(FakeServer): + @override + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/wait"): + fake.requests.append(request) + entered.set() + await anyio.sleep_forever() + return await fake.handle_async_request(request) + + client = AsyncOpencode(transport=Blocking(), base_url="http://opencode.fake") + async with anyio.create_task_group() as group: + group.start_soon(client.session.run, "long task") + await entered.wait() + group.cancel_scope.cancel() + + assert _paths(fake)[-1].endswith("/interrupt") + + +# -- wait_until_ready ----------------------------------------------------------------------------------------- + + +def test_wait_until_ready_polls_until_agents_and_mcp_appear() -> None: + fake = FakeServer(agents=["build"]) + fake.add_agent("reviewer", after_polls=3) + fake.set_mcp_status("github", "connected", after_polls=2) + client = Opencode.fake(fake) + + client.wait_until_ready(agents=["build", "reviewer"], mcp_servers=["github"], timeout=5) + + assert _paths(fake).count("GET /api/agent") == 4 + + +def test_wait_until_ready_times_out_naming_what_is_missing() -> None: + fake = FakeServer() + fake.set_mcp_status("broken", "failed") + client = Opencode.fake(fake) + + with pytest.raises(TimeoutError, match=r"agent 'ghost'.*MCP server 'broken'"): + client.wait_until_ready(agents=["ghost"], mcp_servers=["broken"], timeout=0.05) + + +def test_wait_until_ready_retries_while_server_boots() -> None: + fake = FakeServer() + fake.fail("GET", "/api/info", exception=httpx.ConnectError("refused"), times=2) + client = Opencode.fake(fake, max_retries=0) + + client.wait_until_ready(timeout=5) + + assert _paths(fake).count("GET /api/info") == 3 + + +@pytest.mark.anyio +async def test_async_wait_until_ready() -> None: + fake = FakeServer(agents=[]) + fake.add_agent("build", after_polls=1) + async with AsyncOpencode.fake(fake) as client: + await client.wait_until_ready(agents=["build"], timeout=5) + with pytest.raises(TimeoutError): + await client.wait_until_ready(mcp_servers=["nope"], timeout=0.01) + + +# -- FakeServer ----------------------------------------------------------------------------------------------- + + +def test_fake_session_crud_and_listing() -> None: + fake = FakeServer() + client = Opencode.fake(fake) + created = [client.session.create(title=f"s{index}") for index in range(3)] + + assert client.session.get(created[0].id).title == "s0" + assert [session.title for session in client.session.list(limit=2)] == ["s2", "s1", "s0"] + client.session.remove(created[0].id) + with pytest.raises(SessionNotFoundError): + client.session.get(created[0].id) + + +def test_fake_queued_prompt_does_not_run() -> None: + fake = FakeServer() + client = Opencode.fake(fake) + session = client.session.create() + + queued = client.session.prompt(session.id, text="later", resume=False) + + assert queued.type == "user" + assert queued.payload.text == "later" + assert list(client.message.list(session.id)) == [] + assert len(fake.inbox[session.id]) == 1 + + +def test_fake_message_filter_and_order() -> None: + client = Opencode.fake() + session = client.session.run("hi").session + + assert [message.type for message in client.message.list(session.id, type="assistant")] == ["assistant"] + assert [message.type for message in client.message.list(session.id, order="desc")] == ["idle", "assistant", "user"] + + +def test_fake_event_stream() -> None: + client = Opencode.fake() + client.session.run("hi") + with client.event.subscribe() as events: + received = list(events) + assert isinstance(received[0], ServerConnectedEvent) + assert isinstance(received[1], SessionCreatedEvent) + assert isinstance(received[2], SessionIdleEvent) + + +def test_fake_errors() -> None: + fake = FakeServer() + client = Opencode.fake(fake, max_retries=0) + session = client.session.create() + + with pytest.raises(InvalidRequestError) as invalid: + client.session.prompt(session.id, text="x", extra_body={"text": 1}) + assert invalid.value.status_code == 400 + with pytest.raises(NotFoundError, match="does not implement"): + client.config.get() + + +def test_fake_and_http_client_are_exclusive() -> None: + with pytest.raises(ValueError, match="either"): + Opencode("http://x", transport=FakeServer(), http_client=httpx.Client()) diff --git a/tests/test_live.py b/tests/test_live.py index a7108e8..d181a0b 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -22,7 +22,7 @@ rpc_method, session_id_of, ) -from opencode.types import ServerConnectedEvent +from opencode.types import ModelRef, ServerConnectedEvent pytestmark = [ pytest.mark.live, @@ -151,3 +151,24 @@ async def test_async_shared_events(tmp_path: Path) -> None: assert isinstance(await second.get(), ServerConnectedEvent) with pytest.raises(RpcCallError): await client.rpc(Missing).call(Missing.ping) + + +def test_wait_until_ready(client: Opencode) -> None: + agents = [agent.id for agent in client.agent.list().data] + client.wait_until_ready(agents=agents[:1], timeout=30) + with pytest.raises(TimeoutError, match="no-such-agent"): + client.wait_until_ready(agents=["no-such-agent"], timeout=0.5) + + +def test_run_with_unroutable_model(client: Opencode) -> None: + # A model of a provider that does not exist fails before any network call, so no model is called. + # Never prompt without such a model: the default would use OpenCode's hosted free model. + model = ModelRef(id="none", provider_id="sdk-test-missing") + result = client.session.run("hello", model=model, timeout=30) + assert [message.type for message in result.messages] == ["user", "idle"] + assert result.outcome == "failed" + assert result.error is not None + assert result.text == "" + again = client.session.run("again", session_id=result.session.id, timeout=30) + assert again.messages[0].text == "again" # type: ignore[union-attr] + client.session.remove(result.session.id) From 439a1176321f66eecdc5dace3c3a2538f79cb353 Mon Sep 17 00:00:00 2001 From: shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:30:20 +0530 Subject: [PATCH 2/3] perf: load opencode.types on first use Generated resources now name response types as strings, resolved by type_adapter on first use, and import models only under TYPE_CHECKING. opencode.types is a lazy package attribute. import opencode no longer builds ~640 pydantic models (median ~400ms -> ~265ms locally; the model build moves to the first request, ~130ms). --- codegen/render.py | 21 ++- src/opencode/__init__.py | 15 ++- src/opencode/_base_client.py | 13 +- src/opencode/_events.py | 7 +- src/opencode/_rpc.py | 11 +- src/opencode/lib/_session.py | 40 +++--- src/opencode/resources/agent.py | 24 ++-- src/opencode/resources/command.py | 16 ++- src/opencode/resources/config.py | 22 +-- src/opencode/resources/credential.py | 24 ++-- src/opencode/resources/debug.py | 16 ++- src/opencode/resources/event.py | 14 +- src/opencode/resources/experimental.py | 60 +++++---- src/opencode/resources/file.py | 28 ++-- src/opencode/resources/form.py | 16 ++- src/opencode/resources/generate.py | 16 ++- src/opencode/resources/integration.py | 54 ++++---- src/opencode/resources/location.py | 16 ++- src/opencode/resources/mcp.py | 24 ++-- src/opencode/resources/message.py | 17 +-- src/opencode/resources/migration.py | 22 ++- src/opencode/resources/model.py | 22 +-- src/opencode/resources/permission.py | 56 ++++---- src/opencode/resources/plugin.py | 22 +-- src/opencode/resources/project.py | 26 ++-- src/opencode/resources/provider.py | 24 ++-- src/opencode/resources/pty.py | 44 +++--- src/opencode/resources/reference.py | 16 ++- src/opencode/resources/rpc.py | 16 ++- src/opencode/resources/server.py | 26 ++-- src/opencode/resources/session.py | 177 +++++++++++++------------ src/opencode/resources/shell.py | 36 ++--- src/opencode/resources/skill.py | 16 ++- src/opencode/resources/vcs.py | 42 +++--- src/opencode/resources/voice.py | 16 ++- src/opencode/resources/websearch.py | 24 ++-- src/opencode/resources/worktree.py | 24 ++-- tests/test_models.py | 9 ++ 38 files changed, 596 insertions(+), 476 deletions(-) diff --git a/codegen/render.py b/codegen/render.py index 8e3d9b6..a0a9b50 100644 --- a/codegen/render.py +++ b/codegen/render.py @@ -348,14 +348,14 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i lines += [ " return self._client.page(", *request_options, - f" response_type={_resource_type(operation.response_type or 'Any')},", + f" response_type={_resource_type(operation.response_type or 'Any')!r},", " )", ] elif operation.kind == "stream": lines += [ f" return {awaiting}self._client.stream(", *request_options, - f" item_type={_resource_type(operation.response_type or 'Any')},", + f" item_type={_resource_type(operation.response_type or 'Any')!r},", " )", ] elif operation.kind == "void": @@ -371,7 +371,7 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i lines += [ f" return cast({returns!r}, {awaiting}self._client.request(", *request_options, - f" response_type={_resource_type(operation.response_type or 'Any')},", + f" response_type={_resource_type(operation.response_type or 'Any')!r},", *([" unwrap=True,"] if operation.unwrap else []), " ))", ] @@ -453,7 +453,7 @@ def render_resource_module( [*_resource_classes(resource, errors, is_async=False), *_resource_classes(resource, errors, is_async=True)] ) uses = _names_in(source) - typing_names = sorted({"Any", "Literal", "cast"} & uses) + typing_names = sorted(({"Any", "Literal", "cast"} & uses) | ({"TYPE_CHECKING"} if type_names else set[str]())) abc_names = sorted({"Mapping", "Sequence"} & uses) runtime: list[str] = [] if "encode_path_segment" in uses: @@ -481,7 +481,18 @@ def render_resource_module( *(["from .._streaming import AsyncStream, Stream"] if any(op.kind == "stream" for op in operations) else []), "from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout" + (", OpenEnum" if "OpenEnum" in uses else ""), - *(["from ..types import (", *[f" {name}," for name in type_names], ")"] if type_names else []), + # Models load on first use (see ``type_adapter``), so importing the client does not build them all. + *( + [ + "", + "if TYPE_CHECKING:", + " from ..types import (", + *[f" {name}," for name in type_names], + " )", + ] + if type_names + else [] + ), "", f"__all__ = [{', '.join(repr(prefix + resource.class_name + 'Resource') for prefix in ('', 'Async'))}]\n\n", source, diff --git a/src/opencode/__init__.py b/src/opencode/__init__.py index c1f35ef..c122b20 100644 --- a/src/opencode/__init__.py +++ b/src/opencode/__init__.py @@ -14,7 +14,8 @@ from __future__ import annotations -from . import types +from typing import TYPE_CHECKING, Any + from ._client import AsyncOpencode, Opencode # @@ -108,6 +109,9 @@ rpc_method, ) +if TYPE_CHECKING: + from . import types + # API_VERSION = "2.1.2" """The OpenCode server version this client was generated from.""" @@ -206,3 +210,12 @@ "WorktreeError", # ] + + +def __getattr__(name: str) -> Any: + # `opencode.types` builds several hundred models, so it is imported on first use rather than with the package. + if name == "types": + import importlib + + return importlib.import_module(".types", __name__) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/opencode/_base_client.py b/src/opencode/_base_client.py index ed3ce92..4e998fc 100644 --- a/src/opencode/_base_client.py +++ b/src/opencode/_base_client.py @@ -7,6 +7,7 @@ from __future__ import annotations import base64 +import builtins import email.utils import functools import json @@ -60,8 +61,18 @@ def _cached_adapter(tp: Any) -> pydantic.TypeAdapter[Any]: return pydantic.TypeAdapter(tp) +@functools.cache +def _resolve_type(expr: str) -> Any: + # Generated resources name their response types as strings, so `import opencode` does not build every model. + from . import types + + return eval(expr, {**vars(types), "builtins": builtins}) # expressions come from the generator + + def type_adapter(tp: Any) -> pydantic.TypeAdapter[Any]: - """Returns a cached ``TypeAdapter`` for a response type.""" + """Returns a cached ``TypeAdapter`` for a response type, or for a type expression over ``opencode.types``.""" + if isinstance(tp, str): + tp = _resolve_type(tp) try: return _cached_adapter(tp) except TypeError: # unhashable type expression diff --git a/src/opencode/_events.py b/src/opencode/_events.py index deb2c10..71a2f21 100644 --- a/src/opencode/_events.py +++ b/src/opencode/_events.py @@ -31,7 +31,6 @@ from typing_extensions import Self from ._exceptions import APIConnectionError, APIStatusError, OpencodeError -from .types import ServerConnectedEvent if TYPE_CHECKING: from collections.abc import AsyncIterator, Awaitable, Callable, Iterator @@ -39,7 +38,7 @@ from ._client import AsyncOpencode, Opencode from ._streaming import AsyncStream, Stream - from .types import Event + from .types import Event, ServerConnectedEvent __all__ = [ "SUBSCRIBER_CAPACITY", @@ -302,6 +301,8 @@ def _run(self, connection: _SyncConnection) -> None: def _read(self, connection: _SyncConnection) -> None: """Reads one connection until it ends, setting ``connection.delivered`` once an event arrives.""" + from .types import ServerConnectedEvent + stream = self._client.event.subscribe() with self._lock: connection.stream = stream @@ -605,6 +606,8 @@ async def _run(self, connection: _AsyncConnection) -> None: async def _read(self, connection: _AsyncConnection) -> None: """Reads one connection until it ends, setting ``connection.delivered`` once an event arrives.""" + from .types import ServerConnectedEvent + stream: AsyncStream[Event] = await self._client.event.subscribe() try: stream.on_activity = connection.activity diff --git a/src/opencode/_rpc.py b/src/opencode/_rpc.py index f9b111b..22229fa 100644 --- a/src/opencode/_rpc.py +++ b/src/opencode/_rpc.py @@ -44,7 +44,6 @@ class Counter(RpcDefinition, id="counter"): from ._exceptions import OpencodeError, validation_summary from ._types import NOT_GIVEN, NotGiven from .resources.rpc import AsyncRpcResource, RpcResource -from .types import RpcEvent if TYPE_CHECKING: from collections.abc import Mapping @@ -53,7 +52,7 @@ class Counter(RpcDefinition, id="counter"): from ._client import AsyncOpencode, Opencode from ._events import AsyncSubscription, Subscription from ._types import Headers, Timeout - from .types import Location + from .types import Location, RpcEvent __all__ = [ "AsyncRpcClient", @@ -327,11 +326,15 @@ def call( def events(self) -> Subscription[RpcEvent]: """Subscribes to every event of this RPC, as raw events, on the client's shared event stream.""" + from .types import RpcEvent + prefix = f"rpc.{self.definition.id}." return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix)) def subscribe(self, event: RpcEventDefinition[_T]) -> RpcEvents[_T]: """Subscribes to one event of this RPC, with validated data, on the client's shared event stream.""" + from .types import RpcEvent + _check_event(self.definition, event) wire = self.definition.event_type(event) return RpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event) @@ -396,11 +399,15 @@ async def call( def events(self) -> AsyncSubscription[RpcEvent]: """Subscribes to every event of this RPC, as raw events, on the client's shared event stream.""" + from .types import RpcEvent + prefix = f"rpc.{self.definition.id}." return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix)) def subscribe(self, event: RpcEventDefinition[_T]) -> AsyncRpcEvents[_T]: """Subscribes to one event of this RPC, with validated data, on the client's shared event stream.""" + from .types import RpcEvent + _check_event(self.definition, event) wire = self.definition.event_type(event) return AsyncRpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event) diff --git a/src/opencode/lib/_session.py b/src/opencode/lib/_session.py index c6602f8..798a2b4 100644 --- a/src/opencode/lib/_session.py +++ b/src/opencode/lib/_session.py @@ -14,26 +14,22 @@ from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, NotGiven, Timeout -from ..types import ( - AgentID, - ModelRef, - PromptInputFileAttachment, - SessionID, - SessionInfo, - SessionMessageAssistant, - SessionMessageAssistantText, - SessionMessageIdle, - SessionMessageInfo, - SessionMessageUser, - SessionStructuredError, - TokenUsageInfo, - TokenUsageInfoCache, -) if TYPE_CHECKING: from collections.abc import Sequence from ..resources.session import AsyncSessionResource, SessionResource + from ..types import ( + AgentID, + ModelRef, + PromptInputFileAttachment, + SessionID, + SessionInfo, + SessionMessageAssistant, + SessionMessageInfo, + SessionStructuredError, + TokenUsageInfo, + ) __all__ = ["AsyncSessionHelpers", "RunResult", "SessionHelpers"] @@ -44,17 +40,21 @@ class RunResult: session: SessionInfo """The session as it was after the run. ``session.cost`` and ``session.tokens`` cover the whole session.""" - messages: list[SessionMessageInfo] = field(default_factory=list[SessionMessageInfo]) + messages: list[SessionMessageInfo] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] """Messages this run added, oldest first, starting with the prompt.""" @property def assistant_messages(self) -> list[SessionMessageAssistant]: """The assistant messages of this run, oldest first.""" + from ..types import SessionMessageAssistant + return [message for message in self.messages if isinstance(message, SessionMessageAssistant)] @property def text(self) -> str: """Text of the last assistant message, or ``""`` when the agent produced none.""" + from ..types import SessionMessageAssistantText + for message in reversed(self.assistant_messages): parts = [item.text for item in message.content if isinstance(item, SessionMessageAssistantText)] if parts: @@ -64,6 +64,8 @@ def text(self) -> str: @property def outcome(self) -> str | None: """``succeeded``, ``failed`` or ``interrupted``, from the run's idle marker or the session.""" + from ..types import SessionMessageIdle + for message in reversed(self.messages): if isinstance(message, SessionMessageIdle): return message.outcome @@ -72,6 +74,8 @@ def outcome(self) -> str | None: @property def error(self) -> SessionStructuredError | None: """Why the run failed, from its idle marker or last assistant message, or ``None``.""" + from ..types import SessionMessageAssistant, SessionMessageIdle + for message in reversed(self.messages): if isinstance(message, (SessionMessageIdle, SessionMessageAssistant)) and message.error is not None: return message.error @@ -85,6 +89,8 @@ def cost(self) -> float: @property def tokens(self) -> TokenUsageInfo: """Token usage of this run, summed over its assistant messages.""" + from ..types import TokenUsageInfo, TokenUsageInfoCache + total = TokenUsageInfo(input=0, output=0, reasoning=0, cache=TokenUsageInfoCache(read=0, write=0)) for message in self.assistant_messages: usage = message.tokens @@ -99,6 +105,8 @@ def tokens(self) -> TokenUsageInfo: def _new_messages(newest_first: list[SessionMessageInfo], marker: str | None) -> list[SessionMessageInfo]: + from ..types import SessionMessageUser + collected: list[SessionMessageInfo] = [] for message in newest_first: if marker is not None and message.id == marker: diff --git a/src/opencode/resources/agent.py b/src/opencode/resources/agent.py index 5074724..7399dcf 100644 --- a/src/opencode/resources/agent.py +++ b/src/opencode/resources/agent.py @@ -5,18 +5,20 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - AgentGetResponse, - AgentID, - AgentListResponse, - Location, -) + +if TYPE_CHECKING: + from ..types import ( + AgentGetResponse, + AgentID, + AgentListResponse, + Location, + ) __all__ = ["AgentResource", "AsyncAgentResource"] @@ -56,7 +58,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=AgentListResponse, + response_type="AgentListResponse", ), ) @@ -94,7 +96,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=AgentGetResponse, + response_type="AgentGetResponse", ), ) @@ -134,7 +136,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=AgentListResponse, + response_type="AgentListResponse", ), ) @@ -172,6 +174,6 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=AgentGetResponse, + response_type="AgentGetResponse", ), ) diff --git a/src/opencode/resources/command.py b/src/opencode/resources/command.py index 68291eb..c653b65 100644 --- a/src/opencode/resources/command.py +++ b/src/opencode/resources/command.py @@ -5,15 +5,17 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - CommandListResponse, - Location, -) + +if TYPE_CHECKING: + from ..types import ( + CommandListResponse, + Location, + ) __all__ = ["CommandResource", "AsyncCommandResource"] @@ -53,7 +55,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=CommandListResponse, + response_type="CommandListResponse", ), ) @@ -93,6 +95,6 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=CommandListResponse, + response_type="CommandListResponse", ), ) diff --git a/src/opencode/resources/config.py b/src/opencode/resources/config.py index 18af343..751c4e0 100644 --- a/src/opencode/resources/config.py +++ b/src/opencode/resources/config.py @@ -6,16 +6,18 @@ from __future__ import annotations import builtins -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - ConfigEntry, - ConfigShellOption, - Location, -) + +if TYPE_CHECKING: + from ..types import ( + ConfigEntry, + ConfigShellOption, + Location, + ) __all__ = ["ConfigResource", "AsyncConfigResource"] @@ -55,7 +57,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[ConfigEntry], + response_type="builtins.list[ConfigEntry]", ), ) @@ -89,7 +91,7 @@ def shells( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[ConfigShellOption], + response_type="builtins.list[ConfigShellOption]", ), ) @@ -164,7 +166,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[ConfigEntry], + response_type="builtins.list[ConfigEntry]", ), ) @@ -198,7 +200,7 @@ async def shells( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[ConfigShellOption], + response_type="builtins.list[ConfigShellOption]", ), ) diff --git a/src/opencode/resources/credential.py b/src/opencode/resources/credential.py index 005e545..f7468d8 100644 --- a/src/opencode/resources/credential.py +++ b/src/opencode/resources/credential.py @@ -6,18 +6,20 @@ from __future__ import annotations import builtins -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - CredentialEntry, - CredentialID, - CredentialValue, - IntegrationID, -) + +if TYPE_CHECKING: + from ..types import ( + CredentialEntry, + CredentialID, + CredentialValue, + IntegrationID, + ) __all__ = ["CredentialResource", "AsyncCredentialResource"] @@ -54,7 +56,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[CredentialEntry], + response_type="builtins.list[CredentialEntry]", unwrap=True, ), ) @@ -101,7 +103,7 @@ def create( extra_body=extra_body, timeout=timeout, ), - response_type=CredentialEntry, + response_type="CredentialEntry", unwrap=True, ), ) @@ -234,7 +236,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[CredentialEntry], + response_type="builtins.list[CredentialEntry]", unwrap=True, ), ) @@ -281,7 +283,7 @@ async def create( extra_body=extra_body, timeout=timeout, ), - response_type=CredentialEntry, + response_type="CredentialEntry", unwrap=True, ), ) diff --git a/src/opencode/resources/debug.py b/src/opencode/resources/debug.py index d1f2442..b296dd8 100644 --- a/src/opencode/resources/debug.py +++ b/src/opencode/resources/debug.py @@ -7,15 +7,17 @@ import builtins from functools import cached_property -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - LocationPublicRef, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + LocationPublicRef, + ) __all__ = ["DebugResource", "AsyncDebugResource"] @@ -52,7 +54,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[LocationPublicRef], + response_type="builtins.list[LocationPublicRef]", ), ) @@ -130,7 +132,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[LocationPublicRef], + response_type="builtins.list[LocationPublicRef]", ), ) diff --git a/src/opencode/resources/event.py b/src/opencode/resources/event.py index 0706f59..ede6671 100644 --- a/src/opencode/resources/event.py +++ b/src/opencode/resources/event.py @@ -5,13 +5,17 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._streaming import AsyncStream, Stream from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Event, -) + +if TYPE_CHECKING: + from ..types import ( + Event, + ) __all__ = ["EventResource", "AsyncEventResource"] @@ -46,7 +50,7 @@ def subscribe( extra_body=extra_body, timeout=timeout, ), - item_type=Event, + item_type="Event", ) @@ -80,5 +84,5 @@ async def subscribe( extra_body=extra_body, timeout=timeout, ), - item_type=Event, + item_type="Event", ) diff --git a/src/opencode/resources/experimental.py b/src/opencode/resources/experimental.py index b334390..4a59398 100644 --- a/src/opencode/resources/experimental.py +++ b/src/opencode/resources/experimental.py @@ -8,25 +8,27 @@ import builtins from collections.abc import Mapping, Sequence from functools import cached_property -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._pty import AsyncPersistentPtyAttachMixin, PersistentPtyAttachMixin from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - ExperimentalPersistentPtyCreateSize, - ExperimentalPersistentPtyHandoffResponse, - ExperimentalPersistentPtyUpdateSize, - PersistentPtyInfo, - PersistentPtyReadLines, - PersistentPtyReadResult, - PersistentPtySnapshot, - PtyID, - PtyTicketConnectToken, - SessionID, -) + +if TYPE_CHECKING: + from ..types import ( + ExperimentalPersistentPtyCreateSize, + ExperimentalPersistentPtyHandoffResponse, + ExperimentalPersistentPtyUpdateSize, + PersistentPtyInfo, + PersistentPtyReadLines, + PersistentPtyReadResult, + PersistentPtySnapshot, + PtyID, + PtyTicketConnectToken, + SessionID, + ) __all__ = ["ExperimentalResource", "AsyncExperimentalResource"] @@ -69,7 +71,7 @@ def read( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtyReadResult | None, + response_type="PersistentPtyReadResult | None", unwrap=True, ), ) @@ -103,7 +105,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[PersistentPtyInfo], + response_type="builtins.list[PersistentPtyInfo]", unwrap=True, ), ) @@ -144,7 +146,7 @@ def create( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtyInfo, + response_type="PersistentPtyInfo", unwrap=True, ), ) @@ -206,7 +208,7 @@ def handoff( extra_body=extra_body, timeout=timeout, ), - response_type=ExperimentalPersistentPtyHandoffResponse, + response_type="ExperimentalPersistentPtyHandoffResponse", ), ) @@ -240,7 +242,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtyInfo, + response_type="PersistentPtyInfo", unwrap=True, ), ) @@ -278,7 +280,7 @@ def update( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtyInfo, + response_type="PersistentPtyInfo", unwrap=True, ), ) @@ -313,7 +315,7 @@ def snapshot( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtySnapshot, + response_type="PersistentPtySnapshot", unwrap=True, ), ) @@ -382,7 +384,7 @@ def connect_token( extra_body=extra_body, timeout=timeout, ), - response_type=PtyTicketConnectToken, + response_type="PtyTicketConnectToken", unwrap=True, ), ) @@ -435,7 +437,7 @@ async def read( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtyReadResult | None, + response_type="PersistentPtyReadResult | None", unwrap=True, ), ) @@ -469,7 +471,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[PersistentPtyInfo], + response_type="builtins.list[PersistentPtyInfo]", unwrap=True, ), ) @@ -510,7 +512,7 @@ async def create( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtyInfo, + response_type="PersistentPtyInfo", unwrap=True, ), ) @@ -572,7 +574,7 @@ async def handoff( extra_body=extra_body, timeout=timeout, ), - response_type=ExperimentalPersistentPtyHandoffResponse, + response_type="ExperimentalPersistentPtyHandoffResponse", ), ) @@ -606,7 +608,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtyInfo, + response_type="PersistentPtyInfo", unwrap=True, ), ) @@ -644,7 +646,7 @@ async def update( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtyInfo, + response_type="PersistentPtyInfo", unwrap=True, ), ) @@ -679,7 +681,7 @@ async def snapshot( extra_body=extra_body, timeout=timeout, ), - response_type=PersistentPtySnapshot, + response_type="PersistentPtySnapshot", unwrap=True, ), ) @@ -748,7 +750,7 @@ async def connect_token( extra_body=extra_body, timeout=timeout, ), - response_type=PtyTicketConnectToken, + response_type="PtyTicketConnectToken", unwrap=True, ), ) diff --git a/src/opencode/resources/file.py b/src/opencode/resources/file.py index eebbcc0..8a96de9 100644 --- a/src/opencode/resources/file.py +++ b/src/opencode/resources/file.py @@ -5,18 +5,20 @@ from __future__ import annotations -from typing import Literal, cast +from typing import TYPE_CHECKING, Literal, cast from .._base_client import RequestOptions from .._qs import encode_path from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, OpenEnum, Query, Timeout -from ..types import ( - FileFindResponse, - FileListResponse, - FileWriteResponse, - Location, -) + +if TYPE_CHECKING: + from ..types import ( + FileFindResponse, + FileListResponse, + FileWriteResponse, + Location, + ) __all__ = ["FileResource", "AsyncFileResource"] @@ -99,7 +101,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=FileListResponse, + response_type="FileListResponse", ), ) @@ -138,7 +140,7 @@ def find( extra_body=extra_body, timeout=timeout, ), - response_type=FileFindResponse, + response_type="FileFindResponse", ), ) @@ -182,7 +184,7 @@ def write( extra_body=extra_body, timeout=timeout, ), - response_type=FileWriteResponse, + response_type="FileWriteResponse", ), ) @@ -265,7 +267,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=FileListResponse, + response_type="FileListResponse", ), ) @@ -304,7 +306,7 @@ async def find( extra_body=extra_body, timeout=timeout, ), - response_type=FileFindResponse, + response_type="FileFindResponse", ), ) @@ -348,6 +350,6 @@ async def write( extra_body=extra_body, timeout=timeout, ), - response_type=FileWriteResponse, + response_type="FileWriteResponse", ), ) diff --git a/src/opencode/resources/form.py b/src/opencode/resources/form.py index b467928..4c6f2de 100644 --- a/src/opencode/resources/form.py +++ b/src/opencode/resources/form.py @@ -5,15 +5,17 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - FormListResponse, - Location, -) + +if TYPE_CHECKING: + from ..types import ( + FormListResponse, + Location, + ) __all__ = ["FormResource", "AsyncFormResource"] @@ -53,7 +55,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=FormListResponse, + response_type="FormListResponse", ), ) @@ -93,6 +95,6 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=FormListResponse, + response_type="FormListResponse", ), ) diff --git a/src/opencode/resources/generate.py b/src/opencode/resources/generate.py index e481baa..6788e63 100644 --- a/src/opencode/resources/generate.py +++ b/src/opencode/resources/generate.py @@ -5,15 +5,17 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - GenerateTextResponseData, - ModelRef, -) + +if TYPE_CHECKING: + from ..types import ( + GenerateTextResponseData, + ModelRef, + ) __all__ = ["GenerateResource", "AsyncGenerateResource"] @@ -57,7 +59,7 @@ def text( extra_body=extra_body, timeout=timeout, ), - response_type=GenerateTextResponseData, + response_type="GenerateTextResponseData", unwrap=True, ), ) @@ -102,7 +104,7 @@ async def text( extra_body=extra_body, timeout=timeout, ), - response_type=GenerateTextResponseData, + response_type="GenerateTextResponseData", unwrap=True, ), ) diff --git a/src/opencode/resources/integration.py b/src/opencode/resources/integration.py index 6b86b69..bef4518 100644 --- a/src/opencode/resources/integration.py +++ b/src/opencode/resources/integration.py @@ -6,25 +6,27 @@ from __future__ import annotations from functools import cached_property -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - FormAnswer, - IntegrationAttemptID, - IntegrationCommandConnectResponse, - IntegrationCommandStatusResponse, - IntegrationGetResponse, - IntegrationID, - IntegrationListResponse, - IntegrationMethodID, - IntegrationOauthConnectResponse, - IntegrationOauthStatusResponse, - Location, -) + +if TYPE_CHECKING: + from ..types import ( + FormAnswer, + IntegrationAttemptID, + IntegrationCommandConnectResponse, + IntegrationCommandStatusResponse, + IntegrationGetResponse, + IntegrationID, + IntegrationListResponse, + IntegrationMethodID, + IntegrationOauthConnectResponse, + IntegrationOauthStatusResponse, + Location, + ) __all__ = ["IntegrationResource", "AsyncIntegrationResource"] @@ -192,7 +194,7 @@ def connect( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationOauthConnectResponse, + response_type="IntegrationOauthConnectResponse", ), ) @@ -232,7 +234,7 @@ def status( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationOauthStatusResponse, + response_type="IntegrationOauthStatusResponse", ), ) @@ -352,7 +354,7 @@ def connect( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationCommandConnectResponse, + response_type="IntegrationCommandConnectResponse", ), ) @@ -392,7 +394,7 @@ def status( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationCommandStatusResponse, + response_type="IntegrationCommandStatusResponse", ), ) @@ -487,7 +489,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationListResponse, + response_type="IntegrationListResponse", ), ) @@ -525,7 +527,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationGetResponse, + response_type="IntegrationGetResponse", ), ) @@ -693,7 +695,7 @@ async def connect( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationOauthConnectResponse, + response_type="IntegrationOauthConnectResponse", ), ) @@ -733,7 +735,7 @@ async def status( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationOauthStatusResponse, + response_type="IntegrationOauthStatusResponse", ), ) @@ -853,7 +855,7 @@ async def connect( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationCommandConnectResponse, + response_type="IntegrationCommandConnectResponse", ), ) @@ -893,7 +895,7 @@ async def status( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationCommandStatusResponse, + response_type="IntegrationCommandStatusResponse", ), ) @@ -988,7 +990,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationListResponse, + response_type="IntegrationListResponse", ), ) @@ -1026,6 +1028,6 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=IntegrationGetResponse, + response_type="IntegrationGetResponse", ), ) diff --git a/src/opencode/resources/location.py b/src/opencode/resources/location.py index 653beae..1a54f18 100644 --- a/src/opencode/resources/location.py +++ b/src/opencode/resources/location.py @@ -5,15 +5,17 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - LocationPublicInfo, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + LocationPublicInfo, + ) __all__ = ["LocationResource", "AsyncLocationResource"] @@ -53,7 +55,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=LocationPublicInfo, + response_type="LocationPublicInfo", ), ) @@ -124,7 +126,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=LocationPublicInfo, + response_type="LocationPublicInfo", ), ) diff --git a/src/opencode/resources/mcp.py b/src/opencode/resources/mcp.py index 155456f..5d8d6e5 100644 --- a/src/opencode/resources/mcp.py +++ b/src/opencode/resources/mcp.py @@ -6,18 +6,20 @@ from __future__ import annotations from functools import cached_property -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - McpAddConfig, - McpListResponse, - McpResourceCatalogResponse, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + McpAddConfig, + McpListResponse, + McpResourceCatalogResponse, + ) __all__ = ["McpResource", "AsyncMcpResource"] @@ -57,7 +59,7 @@ def catalog( extra_body=extra_body, timeout=timeout, ), - response_type=McpResourceCatalogResponse, + response_type="McpResourceCatalogResponse", ), ) @@ -102,7 +104,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=McpListResponse, + response_type="McpListResponse", ), ) @@ -291,7 +293,7 @@ async def catalog( extra_body=extra_body, timeout=timeout, ), - response_type=McpResourceCatalogResponse, + response_type="McpResourceCatalogResponse", ), ) @@ -336,7 +338,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=McpListResponse, + response_type="McpListResponse", ), ) diff --git a/src/opencode/resources/message.py b/src/opencode/resources/message.py index 4c35c05..f3e257b 100644 --- a/src/opencode/resources/message.py +++ b/src/opencode/resources/message.py @@ -5,18 +5,19 @@ from __future__ import annotations -from typing import Literal +from typing import TYPE_CHECKING, Literal from .._base_client import RequestOptions from .._pagination import AsyncPaginator, SyncCursorPage from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, OpenEnum, Query, Timeout -from ..types import ( - SessionID, - SessionMessageInfo, - SessionMessagesResponse, -) + +if TYPE_CHECKING: + from ..types import ( + SessionID, + SessionMessageInfo, + ) __all__ = ["MessageResource", "AsyncMessageResource"] @@ -82,7 +83,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=SessionMessagesResponse, + response_type="SessionMessagesResponse", ) @@ -147,5 +148,5 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=SessionMessagesResponse, + response_type="SessionMessagesResponse", ) diff --git a/src/opencode/resources/migration.py b/src/opencode/resources/migration.py index 212ea18..f4ed895 100644 --- a/src/opencode/resources/migration.py +++ b/src/opencode/resources/migration.py @@ -6,16 +6,18 @@ from __future__ import annotations from functools import cached_property -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - MigrationV1StatusResponseOption1, - MigrationV1StatusResponseOption2, - MigrationV1StatusResponseOption3, -) + +if TYPE_CHECKING: + from ..types import ( + MigrationV1StatusResponseOption1, + MigrationV1StatusResponseOption2, + MigrationV1StatusResponseOption3, + ) __all__ = ["MigrationResource", "AsyncMigrationResource"] @@ -54,9 +56,7 @@ def status( extra_body=extra_body, timeout=timeout, ), - response_type=MigrationV1StatusResponseOption1 - | MigrationV1StatusResponseOption2 - | MigrationV1StatusResponseOption3, + response_type="MigrationV1StatusResponseOption1 | MigrationV1StatusResponseOption2 | MigrationV1StatusResponseOption3", ), ) @@ -104,9 +104,7 @@ async def status( extra_body=extra_body, timeout=timeout, ), - response_type=MigrationV1StatusResponseOption1 - | MigrationV1StatusResponseOption2 - | MigrationV1StatusResponseOption3, + response_type="MigrationV1StatusResponseOption1 | MigrationV1StatusResponseOption2 | MigrationV1StatusResponseOption3", ), ) diff --git a/src/opencode/resources/model.py b/src/opencode/resources/model.py index 0fb2c8b..e2484b4 100644 --- a/src/opencode/resources/model.py +++ b/src/opencode/resources/model.py @@ -5,16 +5,18 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - ModelDefaultResponse, - ModelListResponse, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + ModelDefaultResponse, + ModelListResponse, + ) __all__ = ["ModelResource", "AsyncModelResource"] @@ -55,7 +57,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=ModelListResponse, + response_type="ModelListResponse", ), ) @@ -92,7 +94,7 @@ def default( extra_body=extra_body, timeout=timeout, ), - response_type=ModelDefaultResponse, + response_type="ModelDefaultResponse", ), ) @@ -133,7 +135,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=ModelListResponse, + response_type="ModelListResponse", ), ) @@ -170,6 +172,6 @@ async def default( extra_body=extra_body, timeout=timeout, ), - response_type=ModelDefaultResponse, + response_type="ModelDefaultResponse", ), ) diff --git a/src/opencode/resources/permission.py b/src/opencode/resources/permission.py index 4a022ae..7c850af 100644 --- a/src/opencode/resources/permission.py +++ b/src/opencode/resources/permission.py @@ -8,26 +8,28 @@ import builtins from collections.abc import Mapping, Sequence from functools import cached_property -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - AgentID, - Location, - PermissionCreateResponse, - PermissionID, - PermissionReply, - PermissionRequest, - PermissionRequestListResponse, - PermissionSavedID, - PermissionSavedInfo, - PermissionSource, - ProjectID, - SessionID, -) + +if TYPE_CHECKING: + from ..types import ( + AgentID, + Location, + PermissionCreateResponse, + PermissionID, + PermissionReply, + PermissionRequest, + PermissionRequestListResponse, + PermissionSavedID, + PermissionSavedInfo, + PermissionSource, + ProjectID, + SessionID, + ) __all__ = ["PermissionResource", "AsyncPermissionResource"] @@ -67,7 +69,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=PermissionRequestListResponse, + response_type="PermissionRequestListResponse", ), ) @@ -107,7 +109,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[PermissionSavedInfo], + response_type="builtins.list[PermissionSavedInfo]", unwrap=True, ), ) @@ -148,7 +150,7 @@ def update( extra_body=extra_body, timeout=timeout, ), - response_type=PermissionSavedInfo, + response_type="PermissionSavedInfo", unwrap=True, ), ) @@ -247,7 +249,7 @@ def create( extra_body=extra_body, timeout=timeout, ), - response_type=PermissionCreateResponse, + response_type="PermissionCreateResponse", unwrap=True, ), ) @@ -284,7 +286,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[PermissionRequest], + response_type="builtins.list[PermissionRequest]", unwrap=True, ), ) @@ -323,7 +325,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=PermissionRequest, + response_type="PermissionRequest", unwrap=True, ), ) @@ -402,7 +404,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=PermissionRequestListResponse, + response_type="PermissionRequestListResponse", ), ) @@ -442,7 +444,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[PermissionSavedInfo], + response_type="builtins.list[PermissionSavedInfo]", unwrap=True, ), ) @@ -483,7 +485,7 @@ async def update( extra_body=extra_body, timeout=timeout, ), - response_type=PermissionSavedInfo, + response_type="PermissionSavedInfo", unwrap=True, ), ) @@ -582,7 +584,7 @@ async def create( extra_body=extra_body, timeout=timeout, ), - response_type=PermissionCreateResponse, + response_type="PermissionCreateResponse", unwrap=True, ), ) @@ -619,7 +621,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[PermissionRequest], + response_type="builtins.list[PermissionRequest]", unwrap=True, ), ) @@ -658,7 +660,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=PermissionRequest, + response_type="PermissionRequest", unwrap=True, ), ) diff --git a/src/opencode/resources/plugin.py b/src/opencode/resources/plugin.py index 7553245..c71b0f3 100644 --- a/src/opencode/resources/plugin.py +++ b/src/opencode/resources/plugin.py @@ -6,16 +6,18 @@ from __future__ import annotations from collections.abc import Sequence -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - PluginCheckResponse, - PluginListResponse, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + PluginCheckResponse, + PluginListResponse, + ) __all__ = ["PluginResource", "AsyncPluginResource"] @@ -55,7 +57,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=PluginListResponse, + response_type="PluginListResponse", ), ) @@ -93,7 +95,7 @@ def check( extra_body=extra_body, timeout=timeout, ), - response_type=PluginCheckResponse, + response_type="PluginCheckResponse", ), ) @@ -169,7 +171,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=PluginListResponse, + response_type="PluginListResponse", ), ) @@ -207,7 +209,7 @@ async def check( extra_body=extra_body, timeout=timeout, ), - response_type=PluginCheckResponse, + response_type="PluginCheckResponse", ), ) diff --git a/src/opencode/resources/project.py b/src/opencode/resources/project.py index 2e926bf..327c48c 100644 --- a/src/opencode/resources/project.py +++ b/src/opencode/resources/project.py @@ -6,19 +6,21 @@ from __future__ import annotations import builtins -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - AbsolutePath, - Project, - ProjectCommands, - ProjectIcon, - ProjectID, -) + +if TYPE_CHECKING: + from ..types import ( + AbsolutePath, + Project, + ProjectCommands, + ProjectIcon, + ProjectID, + ) __all__ = ["ProjectResource", "AsyncProjectResource"] @@ -58,7 +60,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[Project], + response_type="builtins.list[Project]", ), ) @@ -99,7 +101,7 @@ def update( extra_body=extra_body, timeout=timeout, ), - response_type=Project, + response_type="Project", ), ) @@ -139,7 +141,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[Project], + response_type="builtins.list[Project]", ), ) @@ -180,6 +182,6 @@ async def update( extra_body=extra_body, timeout=timeout, ), - response_type=Project, + response_type="Project", ), ) diff --git a/src/opencode/resources/provider.py b/src/opencode/resources/provider.py index 73e79b5..2caf21a 100644 --- a/src/opencode/resources/provider.py +++ b/src/opencode/resources/provider.py @@ -5,18 +5,20 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - ProviderGetResponse, - ProviderID, - ProviderListResponse, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + ProviderGetResponse, + ProviderID, + ProviderListResponse, + ) __all__ = ["ProviderResource", "AsyncProviderResource"] @@ -57,7 +59,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=ProviderListResponse, + response_type="ProviderListResponse", ), ) @@ -96,7 +98,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=ProviderGetResponse, + response_type="ProviderGetResponse", ), ) @@ -137,7 +139,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=ProviderListResponse, + response_type="ProviderListResponse", ), ) @@ -176,6 +178,6 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=ProviderGetResponse, + response_type="ProviderGetResponse", ), ) diff --git a/src/opencode/resources/pty.py b/src/opencode/resources/pty.py index 3fa2bea..e82e79f 100644 --- a/src/opencode/resources/pty.py +++ b/src/opencode/resources/pty.py @@ -7,23 +7,25 @@ from collections.abc import Mapping, Sequence from functools import cached_property -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._pty import AsyncPtyAttachMixin, PtyAttachMixin from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - PtyConnectTokenResponse, - PtyCreateResponse, - PtyGetResponse, - PtyID, - PtyListResponse, - PtyUpdateResponse, - PtyUpdateSize, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + PtyConnectTokenResponse, + PtyCreateResponse, + PtyGetResponse, + PtyID, + PtyListResponse, + PtyUpdateResponse, + PtyUpdateSize, + ) __all__ = ["PtyResource", "AsyncPtyResource"] @@ -68,7 +70,7 @@ def token( extra_body=extra_body, timeout=timeout, ), - response_type=PtyConnectTokenResponse, + response_type="PtyConnectTokenResponse", ), ) @@ -113,7 +115,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=PtyListResponse, + response_type="PtyListResponse", ), ) @@ -155,7 +157,7 @@ def create( extra_body=extra_body, timeout=timeout, ), - response_type=PtyCreateResponse, + response_type="PtyCreateResponse", ), ) @@ -193,7 +195,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=PtyGetResponse, + response_type="PtyGetResponse", ), ) @@ -234,7 +236,7 @@ def update( extra_body=extra_body, timeout=timeout, ), - response_type=PtyUpdateResponse, + response_type="PtyUpdateResponse", ), ) @@ -314,7 +316,7 @@ async def token( extra_body=extra_body, timeout=timeout, ), - response_type=PtyConnectTokenResponse, + response_type="PtyConnectTokenResponse", ), ) @@ -359,7 +361,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=PtyListResponse, + response_type="PtyListResponse", ), ) @@ -401,7 +403,7 @@ async def create( extra_body=extra_body, timeout=timeout, ), - response_type=PtyCreateResponse, + response_type="PtyCreateResponse", ), ) @@ -439,7 +441,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=PtyGetResponse, + response_type="PtyGetResponse", ), ) @@ -480,7 +482,7 @@ async def update( extra_body=extra_body, timeout=timeout, ), - response_type=PtyUpdateResponse, + response_type="PtyUpdateResponse", ), ) diff --git a/src/opencode/resources/reference.py b/src/opencode/resources/reference.py index 869efef..f9fba25 100644 --- a/src/opencode/resources/reference.py +++ b/src/opencode/resources/reference.py @@ -5,15 +5,17 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - ReferenceListResponse, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + ReferenceListResponse, + ) __all__ = ["ReferenceResource", "AsyncReferenceResource"] @@ -53,7 +55,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=ReferenceListResponse, + response_type="ReferenceListResponse", ), ) @@ -93,6 +95,6 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=ReferenceListResponse, + response_type="ReferenceListResponse", ), ) diff --git a/src/opencode/resources/rpc.py b/src/opencode/resources/rpc.py index c00d41f..85a9987 100644 --- a/src/opencode/resources/rpc.py +++ b/src/opencode/resources/rpc.py @@ -5,16 +5,18 @@ from __future__ import annotations -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - RpcOutput, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + RpcOutput, + ) __all__ = ["RpcResource", "AsyncRpcResource"] @@ -60,7 +62,7 @@ def call( extra_body=extra_body, timeout=timeout, ), - response_type=RpcOutput, + response_type="RpcOutput", ), ) @@ -106,6 +108,6 @@ async def call( extra_body=extra_body, timeout=timeout, ), - response_type=RpcOutput, + response_type="RpcOutput", ), ) diff --git a/src/opencode/resources/server.py b/src/opencode/resources/server.py index e16c9cb..f5ac9c5 100644 --- a/src/opencode/resources/server.py +++ b/src/opencode/resources/server.py @@ -5,17 +5,19 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - PairingCode, - PairingSession, - ServerInfo, -) + +if TYPE_CHECKING: + from ..types import ( + PairingCode, + PairingSession, + ServerInfo, + ) __all__ = ["ServerResource", "AsyncServerResource"] @@ -52,7 +54,7 @@ def info( extra_body=extra_body, timeout=timeout, ), - response_type=ServerInfo, + response_type="ServerInfo", ), ) @@ -85,7 +87,7 @@ def pair( extra_body=extra_body, timeout=timeout, ), - response_type=PairingCode, + response_type="PairingCode", ), ) @@ -119,7 +121,7 @@ def connect( extra_body=extra_body, timeout=timeout, ), - response_type=PairingSession, + response_type="PairingSession", ), ) @@ -156,7 +158,7 @@ async def info( extra_body=extra_body, timeout=timeout, ), - response_type=ServerInfo, + response_type="ServerInfo", ), ) @@ -189,7 +191,7 @@ async def pair( extra_body=extra_body, timeout=timeout, ), - response_type=PairingCode, + response_type="PairingCode", ), ) @@ -223,6 +225,6 @@ async def connect( extra_body=extra_body, timeout=timeout, ), - response_type=PairingSession, + response_type="PairingSession", ), ) diff --git a/src/opencode/resources/session.py b/src/opencode/resources/session.py index 5112e8c..6635edc 100644 --- a/src/opencode/resources/session.py +++ b/src/opencode/resources/session.py @@ -8,7 +8,7 @@ import builtins from collections.abc import Mapping, Sequence from functools import cached_property -from typing import Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast from .._base_client import RequestOptions from .._pagination import AsyncPaginator, SyncCursorPage @@ -17,47 +17,48 @@ from .._streaming import AsyncStream, Stream from .._types import NOT_GIVEN, Body, Headers, NotGiven, OpenEnum, Query, Timeout from ..lib._session import AsyncSessionHelpers, SessionHelpers -from ..types import ( - AbsolutePath, - AgentID, - EventSeq, - FileDiffInfo, - FormAnswer, - FormDetail, - FormFields, - FormID, - FormInfo, - FormMetadata, - InstructionEntryInfo, - InstructionEntryKey, - LocationPublicRef, - ModelRef, - PermissionRuleset, - ProjectID, - PromptAgentAttachment, - PromptInputFileAttachment, - PromptInputSkillAttachment, - RelativePath, - SessionGenerateResponseData, - SessionID, - SessionInboxCompaction, - SessionInboxDelivery, - SessionInboxInfo, - SessionInboxSynthetic, - SessionInboxUser, - SessionInfo, - SessionInterruptResponse, - SessionLogItem, - SessionMessageID, - SessionMessageInfo, - SessionMetadata, - SessionRevert, - SessionsCursor, - SessionsResponse, - SessionStatsInfo, - SessionTransferData, - SkillID, -) + +if TYPE_CHECKING: + from ..types import ( + AbsolutePath, + AgentID, + EventSeq, + FileDiffInfo, + FormAnswer, + FormDetail, + FormFields, + FormID, + FormInfo, + FormMetadata, + InstructionEntryInfo, + InstructionEntryKey, + LocationPublicRef, + ModelRef, + PermissionRuleset, + ProjectID, + PromptAgentAttachment, + PromptInputFileAttachment, + PromptInputSkillAttachment, + RelativePath, + SessionGenerateResponseData, + SessionID, + SessionInboxCompaction, + SessionInboxDelivery, + SessionInboxInfo, + SessionInboxSynthetic, + SessionInboxUser, + SessionInfo, + SessionInterruptResponse, + SessionLogItem, + SessionMessageID, + SessionMessageInfo, + SessionMetadata, + SessionRevert, + SessionsCursor, + SessionStatsInfo, + SessionTransferData, + SkillID, + ) __all__ = ["SessionResource", "AsyncSessionResource"] @@ -103,7 +104,7 @@ def stage( extra_body=extra_body, timeout=timeout, ), - response_type=SessionRevert, + response_type="SessionRevert", unwrap=True, ), ) @@ -208,7 +209,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[SessionInboxInfo], + response_type="builtins.list[SessionInboxInfo]", unwrap=True, ), ) @@ -320,7 +321,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[InstructionEntryInfo], + response_type="builtins.list[InstructionEntryInfo]", unwrap=True, ), ) @@ -446,7 +447,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=SessionMessageInfo, + response_type="SessionMessageInfo", unwrap=True, ), ) @@ -487,7 +488,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[FormInfo], + response_type="builtins.list[FormInfo]", unwrap=True, ), ) @@ -530,7 +531,7 @@ def create( extra_body=extra_body, timeout=timeout, ), - response_type=FormInfo, + response_type="FormInfo", unwrap=True, ), ) @@ -569,7 +570,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=FormDetail, + response_type="FormDetail", unwrap=True, ), ) @@ -734,7 +735,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=SessionsResponse, + response_type="SessionsResponse", ) def stats( @@ -774,7 +775,7 @@ def stats( extra_body=extra_body, timeout=timeout, ), - response_type=SessionStatsInfo, + response_type="SessionStatsInfo", unwrap=True, ), ) @@ -827,7 +828,7 @@ def create( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInfo, + response_type="SessionInfo", unwrap=True, ), ) @@ -869,7 +870,7 @@ def import_( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInfo, + response_type="SessionInfo", unwrap=True, ), ) @@ -910,7 +911,7 @@ def export( extra_body=extra_body, timeout=timeout, ), - response_type=SessionTransferData, + response_type="SessionTransferData", unwrap=True, ), ) @@ -944,7 +945,7 @@ def active( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.dict[str, Any], + response_type="builtins.dict[str, Any]", unwrap=True, ), ) @@ -980,7 +981,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInfo, + response_type="SessionInfo", unwrap=True, ), ) @@ -1052,7 +1053,7 @@ def fork( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInfo, + response_type="SessionInfo", unwrap=True, ), ) @@ -1254,7 +1255,7 @@ def prompt( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInboxUser, + response_type="SessionInboxUser", unwrap=True, ), ) @@ -1394,7 +1395,7 @@ def synthetic( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInboxSynthetic, + response_type="SessionInboxSynthetic", unwrap=True, ), ) @@ -1471,7 +1472,7 @@ def compact( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInboxCompaction, + response_type="SessionInboxCompaction", unwrap=True, ), ) @@ -1544,7 +1545,7 @@ def context( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[SessionMessageInfo], + response_type="builtins.list[SessionMessageInfo]", unwrap=True, ), ) @@ -1592,7 +1593,7 @@ def diff( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[FileDiffInfo], + response_type="builtins.list[FileDiffInfo]", unwrap=True, ), ) @@ -1632,7 +1633,7 @@ def generate( extra_body=extra_body, timeout=timeout, ), - response_type=SessionGenerateResponseData, + response_type="SessionGenerateResponseData", unwrap=True, ), ) @@ -1671,7 +1672,7 @@ def log( extra_body=extra_body, timeout=timeout, ), - item_type=SessionLogItem, + item_type="SessionLogItem", ) def interrupt( @@ -1708,7 +1709,7 @@ def interrupt( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInterruptResponse, + response_type="SessionInterruptResponse", ), ) @@ -1855,7 +1856,7 @@ async def stage( extra_body=extra_body, timeout=timeout, ), - response_type=SessionRevert, + response_type="SessionRevert", unwrap=True, ), ) @@ -1960,7 +1961,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[SessionInboxInfo], + response_type="builtins.list[SessionInboxInfo]", unwrap=True, ), ) @@ -2072,7 +2073,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[InstructionEntryInfo], + response_type="builtins.list[InstructionEntryInfo]", unwrap=True, ), ) @@ -2198,7 +2199,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=SessionMessageInfo, + response_type="SessionMessageInfo", unwrap=True, ), ) @@ -2239,7 +2240,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[FormInfo], + response_type="builtins.list[FormInfo]", unwrap=True, ), ) @@ -2282,7 +2283,7 @@ async def create( extra_body=extra_body, timeout=timeout, ), - response_type=FormInfo, + response_type="FormInfo", unwrap=True, ), ) @@ -2321,7 +2322,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=FormDetail, + response_type="FormDetail", unwrap=True, ), ) @@ -2486,7 +2487,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=SessionsResponse, + response_type="SessionsResponse", ) async def stats( @@ -2526,7 +2527,7 @@ async def stats( extra_body=extra_body, timeout=timeout, ), - response_type=SessionStatsInfo, + response_type="SessionStatsInfo", unwrap=True, ), ) @@ -2579,7 +2580,7 @@ async def create( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInfo, + response_type="SessionInfo", unwrap=True, ), ) @@ -2621,7 +2622,7 @@ async def import_( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInfo, + response_type="SessionInfo", unwrap=True, ), ) @@ -2662,7 +2663,7 @@ async def export( extra_body=extra_body, timeout=timeout, ), - response_type=SessionTransferData, + response_type="SessionTransferData", unwrap=True, ), ) @@ -2696,7 +2697,7 @@ async def active( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.dict[str, Any], + response_type="builtins.dict[str, Any]", unwrap=True, ), ) @@ -2732,7 +2733,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInfo, + response_type="SessionInfo", unwrap=True, ), ) @@ -2804,7 +2805,7 @@ async def fork( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInfo, + response_type="SessionInfo", unwrap=True, ), ) @@ -3006,7 +3007,7 @@ async def prompt( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInboxUser, + response_type="SessionInboxUser", unwrap=True, ), ) @@ -3146,7 +3147,7 @@ async def synthetic( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInboxSynthetic, + response_type="SessionInboxSynthetic", unwrap=True, ), ) @@ -3223,7 +3224,7 @@ async def compact( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInboxCompaction, + response_type="SessionInboxCompaction", unwrap=True, ), ) @@ -3296,7 +3297,7 @@ async def context( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[SessionMessageInfo], + response_type="builtins.list[SessionMessageInfo]", unwrap=True, ), ) @@ -3344,7 +3345,7 @@ async def diff( extra_body=extra_body, timeout=timeout, ), - response_type=builtins.list[FileDiffInfo], + response_type="builtins.list[FileDiffInfo]", unwrap=True, ), ) @@ -3384,7 +3385,7 @@ async def generate( extra_body=extra_body, timeout=timeout, ), - response_type=SessionGenerateResponseData, + response_type="SessionGenerateResponseData", unwrap=True, ), ) @@ -3423,7 +3424,7 @@ async def log( extra_body=extra_body, timeout=timeout, ), - item_type=SessionLogItem, + item_type="SessionLogItem", ) async def interrupt( @@ -3460,7 +3461,7 @@ async def interrupt( extra_body=extra_body, timeout=timeout, ), - response_type=SessionInterruptResponse, + response_type="SessionInterruptResponse", ), ) diff --git a/src/opencode/resources/shell.py b/src/opencode/resources/shell.py index 9f84f7f..6210b02 100644 --- a/src/opencode/resources/shell.py +++ b/src/opencode/resources/shell.py @@ -6,20 +6,22 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from .._base_client import RequestOptions from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - ShellCreateResponse, - ShellGetResponse, - ShellID, - ShellListResponse, - ShellOutputResponse, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + ShellCreateResponse, + ShellGetResponse, + ShellID, + ShellListResponse, + ShellOutputResponse, + ) __all__ = ["ShellResource", "AsyncShellResource"] @@ -59,7 +61,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=ShellListResponse, + response_type="ShellListResponse", ), ) @@ -100,7 +102,7 @@ def create( extra_body=extra_body, timeout=timeout, ), - response_type=ShellCreateResponse, + response_type="ShellCreateResponse", ), ) @@ -138,7 +140,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=ShellGetResponse, + response_type="ShellGetResponse", ), ) @@ -178,7 +180,7 @@ def output( extra_body=extra_body, timeout=timeout, ), - response_type=ShellOutputResponse, + response_type="ShellOutputResponse", ), ) @@ -252,7 +254,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=ShellListResponse, + response_type="ShellListResponse", ), ) @@ -293,7 +295,7 @@ async def create( extra_body=extra_body, timeout=timeout, ), - response_type=ShellCreateResponse, + response_type="ShellCreateResponse", ), ) @@ -331,7 +333,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=ShellGetResponse, + response_type="ShellGetResponse", ), ) @@ -371,7 +373,7 @@ async def output( extra_body=extra_body, timeout=timeout, ), - response_type=ShellOutputResponse, + response_type="ShellOutputResponse", ), ) diff --git a/src/opencode/resources/skill.py b/src/opencode/resources/skill.py index 37ce7fe..f4bab9b 100644 --- a/src/opencode/resources/skill.py +++ b/src/opencode/resources/skill.py @@ -5,15 +5,17 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - SkillListResponse, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + SkillListResponse, + ) __all__ = ["SkillResource", "AsyncSkillResource"] @@ -53,7 +55,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=SkillListResponse, + response_type="SkillListResponse", ), ) @@ -93,6 +95,6 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=SkillListResponse, + response_type="SkillListResponse", ), ) diff --git a/src/opencode/resources/vcs.py b/src/opencode/resources/vcs.py index 85365a2..c14ba5c 100644 --- a/src/opencode/resources/vcs.py +++ b/src/opencode/resources/vcs.py @@ -6,20 +6,22 @@ from __future__ import annotations from functools import cached_property -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - VcsBaseResponse, - VcsBranchListResponse, - VcsDiffResponse, - VcsGetResponse, - VcsMode, - VcsStatusResponse, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + VcsBaseResponse, + VcsBranchListResponse, + VcsDiffResponse, + VcsGetResponse, + VcsMode, + VcsStatusResponse, + ) __all__ = ["VcsResource", "AsyncVcsResource"] @@ -61,7 +63,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=VcsBranchListResponse, + response_type="VcsBranchListResponse", ), ) @@ -143,7 +145,7 @@ def get( extra_body=extra_body, timeout=timeout, ), - response_type=VcsGetResponse, + response_type="VcsGetResponse", ), ) @@ -180,7 +182,7 @@ def base( extra_body=extra_body, timeout=timeout, ), - response_type=VcsBaseResponse, + response_type="VcsBaseResponse", ), ) @@ -216,7 +218,7 @@ def status( extra_body=extra_body, timeout=timeout, ), - response_type=VcsStatusResponse, + response_type="VcsStatusResponse", ), ) @@ -256,7 +258,7 @@ def diff( extra_body=extra_body, timeout=timeout, ), - response_type=VcsDiffResponse, + response_type="VcsDiffResponse", ), ) @@ -298,7 +300,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=VcsBranchListResponse, + response_type="VcsBranchListResponse", ), ) @@ -380,7 +382,7 @@ async def get( extra_body=extra_body, timeout=timeout, ), - response_type=VcsGetResponse, + response_type="VcsGetResponse", ), ) @@ -417,7 +419,7 @@ async def base( extra_body=extra_body, timeout=timeout, ), - response_type=VcsBaseResponse, + response_type="VcsBaseResponse", ), ) @@ -453,7 +455,7 @@ async def status( extra_body=extra_body, timeout=timeout, ), - response_type=VcsStatusResponse, + response_type="VcsStatusResponse", ), ) @@ -493,6 +495,6 @@ async def diff( extra_body=extra_body, timeout=timeout, ), - response_type=VcsDiffResponse, + response_type="VcsDiffResponse", ), ) diff --git a/src/opencode/resources/voice.py b/src/opencode/resources/voice.py index b823009..ae2f608 100644 --- a/src/opencode/resources/voice.py +++ b/src/opencode/resources/voice.py @@ -6,15 +6,17 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - VoiceSessionResponseData, - VoiceTransportInput, -) + +if TYPE_CHECKING: + from ..types import ( + VoiceSessionResponseData, + VoiceTransportInput, + ) __all__ = ["VoiceResource", "AsyncVoiceResource"] @@ -59,7 +61,7 @@ def session( extra_body=extra_body, timeout=timeout, ), - response_type=VoiceSessionResponseData, + response_type="VoiceSessionResponseData", unwrap=True, ), ) @@ -105,7 +107,7 @@ async def session( extra_body=extra_body, timeout=timeout, ), - response_type=VoiceSessionResponseData, + response_type="VoiceSessionResponseData", unwrap=True, ), ) diff --git a/src/opencode/resources/websearch.py b/src/opencode/resources/websearch.py index 707f3ea..a232751 100644 --- a/src/opencode/resources/websearch.py +++ b/src/opencode/resources/websearch.py @@ -5,17 +5,19 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - Location, - WebSearchID, - WebsearchProvidersResponse, - WebsearchQueryResponse, -) + +if TYPE_CHECKING: + from ..types import ( + Location, + WebSearchID, + WebsearchProvidersResponse, + WebsearchQueryResponse, + ) __all__ = ["WebsearchResource", "AsyncWebsearchResource"] @@ -56,7 +58,7 @@ def providers( extra_body=extra_body, timeout=timeout, ), - response_type=WebsearchProvidersResponse, + response_type="WebsearchProvidersResponse", ), ) @@ -96,7 +98,7 @@ def query( extra_body=extra_body, timeout=timeout, ), - response_type=WebsearchQueryResponse, + response_type="WebsearchQueryResponse", ), ) @@ -137,7 +139,7 @@ async def providers( extra_body=extra_body, timeout=timeout, ), - response_type=WebsearchProvidersResponse, + response_type="WebsearchProvidersResponse", ), ) @@ -177,6 +179,6 @@ async def query( extra_body=extra_body, timeout=timeout, ), - response_type=WebsearchQueryResponse, + response_type="WebsearchQueryResponse", ), ) diff --git a/src/opencode/resources/worktree.py b/src/opencode/resources/worktree.py index 986ac95..4ddaaf2 100644 --- a/src/opencode/resources/worktree.py +++ b/src/opencode/resources/worktree.py @@ -5,17 +5,19 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from .._base_client import RequestOptions from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout -from ..types import ( - AbsolutePath, - ProjectID, - WorktreeInfo, - WorktreeList, -) + +if TYPE_CHECKING: + from ..types import ( + AbsolutePath, + ProjectID, + WorktreeInfo, + WorktreeList, + ) __all__ = ["WorktreeResource", "AsyncWorktreeResource"] @@ -55,7 +57,7 @@ def list( extra_body=extra_body, timeout=timeout, ), - response_type=WorktreeList, + response_type="WorktreeList", ), ) @@ -102,7 +104,7 @@ def create( extra_body=extra_body, timeout=timeout, ), - response_type=WorktreeInfo, + response_type="WorktreeInfo", ), ) @@ -212,7 +214,7 @@ async def list( extra_body=extra_body, timeout=timeout, ), - response_type=WorktreeList, + response_type="WorktreeList", ), ) @@ -259,7 +261,7 @@ async def create( extra_body=extra_body, timeout=timeout, ), - response_type=WorktreeInfo, + response_type="WorktreeInfo", ), ) diff --git a/tests/test_models.py b/tests/test_models.py index ed75fee..57fd820 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -66,3 +66,12 @@ def test_rpc_events_match_by_pattern() -> None: def test_open_enum_accepts_new_values() -> None: assert ConfigAgent.model_validate({"mode": "primary"}).mode == "primary" assert ConfigAgent.model_validate({"mode": "orchestrator"}).mode == "orchestrator" + + +def test_import_does_not_build_models() -> None: + import subprocess + import sys + + code = "import sys, opencode; opencode.Opencode('http://x'); print('opencode.types._generated' in sys.modules)" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True) + assert result.stdout.strip() == "False" From 4eb17d682da6202c1fc7930ba00cf722412d93ca Mon Sep 17 00:00:00 2001 From: shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:32:11 +0530 Subject: [PATCH 3/3] docs: guides for headless runs and the fake server, reference entries --- docs/guides/headless.md | 75 +++++++++++++++++++++++++++++++++++++++ docs/guides/index.md | 2 +- docs/guides/testing.md | 57 +++++++++++++++++++++++++++++ docs/reference/clients.md | 4 +-- docs/reference/testing.md | 13 +++++++ mkdocs.yml | 3 ++ 6 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 docs/guides/headless.md create mode 100644 docs/guides/testing.md create mode 100644 docs/reference/testing.md diff --git a/docs/guides/headless.md b/docs/guides/headless.md new file mode 100644 index 0000000..54179a9 --- /dev/null +++ b/docs/guides/headless.md @@ -0,0 +1,75 @@ +# Running agents headless + +The common embedded use of OpenCode is: start the server, wait until the agents you configured have loaded, run +one prompt per unit of work, and read the result. Two helpers cover that on both clients. + +## Wait until the server is ready + +Agents load and MCP servers connect in the background after `opencode serve` starts listening, so the first +requests can arrive before your agent exists. `wait_until_ready` polls until everything you name is there: + +```python +client.wait_until_ready(agents=["reviewer"], mcp_servers=["github"], timeout=60) +``` + +It treats connection errors and `503` answers as "still booting". After `timeout` seconds it raises +`TimeoutError` naming what is still missing. + +## Run a prompt to completion + +```python +from opencode.types import ModelRef + +result = client.session.run( + "Review the staged changes", + agent="reviewer", + model=ModelRef(provider_id="anthropic", id="claude-sonnet-5-5"), + timeout=600, +) +print(result.outcome) # "succeeded", "failed" or "interrupted" +print(result.text) # text of the last assistant message +print(result.cost, result.tokens.input, result.tokens.output) +``` + +`run` creates a session with the agent and model, sends the prompt, blocks on `session.wait` until the session is +idle, then returns a [`opencode.lib.RunResult`](../reference/testing.md#opencode.lib.RunResult): + +- `messages`: the messages this run added, oldest first, starting with the prompt +- `text`, `outcome`, `error`: the final answer and how the run ended +- `cost`, `tokens`: usage of this run; `session.cost` and `session.tokens` are the session totals + +Set `model` together with `agent` when creating a session: otherwise the session can run on a different model +than the agent's. + +Pass `session_id=` to continue a session. `agent` and `model` then switch the session before the prompt, and +`messages` holds only the new messages. + +### Interrupts + +If waiting ends early, through `KeyboardInterrupt`, cancelling the task (async), the `timeout`, or a connection +error, `run` calls `session.interrupt` before re-raising, so the agent does not keep running on the server. + +### Files + +`files` takes `PromptInputFileAttachment`s. Images and other inline content can be `data:` URLs: + +```python +import base64 +from opencode.types import PromptInputFileAttachment + +png = base64.b64encode(open("screenshot.png", "rb").read()).decode() +client.session.run( + "What is wrong in this screenshot?", + files=[PromptInputFileAttachment(uri=f"data:image/png;base64,{png}", name="screenshot.png")], +) +``` + +## Async + +The same helpers exist on `AsyncOpencode`: + +```python +async with AsyncOpencode(url, password=password) as client: + await client.wait_until_ready(agents=["build"]) + result = await client.session.run("Summarize README.md", agent="build") +``` diff --git a/docs/guides/index.md b/docs/guides/index.md index 31cc8ff..56a5ed5 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -30,7 +30,7 @@ entry; the common ones also have a guide. CI fails if a group on the client is m | `client.reference` | References | - | [`ReferenceResource`](../reference/resources.md#opencode.resources.reference.ReferenceResource) | | `client.rpc` | Plugin RPC | [Plugin RPC](plugin-rpc.md) | [`RpcResource`](../reference/resources.md#opencode.resources.rpc.RpcResource) | | `client.server` | Server info and health | - | [`ServerResource`](../reference/resources.md#opencode.resources.server.ServerResource) | -| `client.session` | Sessions, prompts and their sub-resources | [Guide](sessions.md) | [`SessionResource`](../reference/resources.md#opencode.resources.session.SessionResource) | +| `client.session` | Sessions, prompts and their sub-resources; `run` for one-call prompts | [Guide](sessions.md), [headless](headless.md) | [`SessionResource`](../reference/resources.md#opencode.resources.session.SessionResource) | | `client.shell` | Shells | - | [`ShellResource`](../reference/resources.md#opencode.resources.shell.ShellResource) | | `client.skill` | Skills | - | [`SkillResource`](../reference/resources.md#opencode.resources.skill.SkillResource) | | `client.vcs` | Version control status and branches | - | [`VcsResource`](../reference/resources.md#opencode.resources.vcs.VcsResource) | diff --git a/docs/guides/testing.md b/docs/guides/testing.md new file mode 100644 index 0000000..588ff12 --- /dev/null +++ b/docs/guides/testing.md @@ -0,0 +1,57 @@ +# Testing with the fake server + +`opencode.testing.FakeServer` lets you test code that uses the SDK without starting `opencode serve` or calling +a model. It is an `httpx` transport (an `httpx.MockTransport`) that keeps sessions, messages and events in +memory and answers with payloads built from the generated models. + +```python +from opencode import Opencode +from opencode.testing import FakeServer + + +def test_review() -> None: + fake = FakeServer(reply=lambda prompt: "LGTM" if "diff" in prompt else "?") + client = Opencode.fake(fake) # same as Opencode(transport=fake) + + result = client.session.run("Review this diff", agent="build") + + assert result.text == "LGTM" + assert fake.prompts == ["Review this diff"] +``` + +`AsyncOpencode.fake(fake)` works the same way. + +## What it implements + +Server info, agents, MCP servers, the event stream, and sessions: create, get, list, remove, switch agent or +model, prompt (with `resume=False` queueing the prompt without running it), wait, interrupt and messages, with +cursor pagination. Each prompt gets an assistant reply, an idle marker, cost and token usage. Other routes +answer `404` with a message naming the route. + +## Simulating a slow boot + +```python +fake = FakeServer(agents=["build"]) +fake.add_agent("reviewer", after_polls=3) # listed from the 4th GET /api/agent +fake.set_mcp_status("github", "connected", after_polls=2) +Opencode.fake(fake).wait_until_ready(agents=["reviewer"], mcp_servers=["github"]) +``` + +## Injecting failures + +```python +fake.fail( + "POST", + r"/api/session/.+/prompt", + status=409, + body={"_tag": "SessionBusyError", "sessionID": "ses_1", "message": "busy"}, +) +fake.fail("GET", "/api/info", exception=httpx.ConnectError("refused"), times=2) +``` + +`path` is a regular expression matched against the whole path. `times=None` fails every matching request. + +## Inspecting state + +`fake.requests` records every `httpx.Request`; `fake.sessions`, `fake.messages`, `fake.inbox` and `fake.events` +hold the server state and can be changed directly. diff --git a/docs/reference/clients.md b/docs/reference/clients.md index 547291b..91a9e82 100644 --- a/docs/reference/clients.md +++ b/docs/reference/clients.md @@ -2,12 +2,12 @@ ::: opencode.Opencode options: - members: [with_options, close] + members: [with_options, close, wait_until_ready, fake] show_bases: false ::: opencode.AsyncOpencode options: - members: [with_options, close] + members: [with_options, close, wait_until_ready, fake] show_bases: false ## Module constants diff --git a/docs/reference/testing.md b/docs/reference/testing.md new file mode 100644 index 0000000..a680808 --- /dev/null +++ b/docs/reference/testing.md @@ -0,0 +1,13 @@ +# Helpers and testing + +## `session.run` result + +::: opencode.lib.RunResult + +## Fake server + +::: opencode.testing.FakeServer + options: + members: [add_agent, set_mcp_status, fail, prompts] + +::: opencode.testing.DEFAULT_MODEL diff --git a/mkdocs.yml b/mkdocs.yml index 35423f5..1e47f8d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -135,6 +135,8 @@ nav: - Guides: - guides/index.md - Sessions and prompts: guides/sessions.md + - Running agents headless: guides/headless.md + - Testing with the fake server: guides/testing.md - Async: guides/async.md - Models and unions: guides/models.md - Pagination: guides/pagination.md @@ -155,6 +157,7 @@ nav: - Shared events and RPC: reference/events.md - Terminals: reference/terminals.md - Exceptions: reference/exceptions.md + - Helpers and testing: reference/testing.md - Types: reference/types/index.md - Project: - How it's built: project/how-its-built.md