285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""OpenCode HTTP + SSE implementation of :class:`NativeServerTransport`.
|
|
|
|
All OpenCode wire details live here;
|
|
:class:`omnigent.native_server_harness.NativeServerHarness` drives it through
|
|
the transport protocol only.
|
|
|
|
The transport can build its client from three sources, in priority order:
|
|
an injected ``client_factory`` (tests), a running
|
|
:class:`OpenCodeNativeServer` (runner-side), or the persisted bridge state
|
|
(harness-side, where only the URL + auth secret are known).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import AsyncIterator, Callable, Mapping
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from omnigent.native_server_transport import (
|
|
NativeEvent,
|
|
NativeLaunchConfig,
|
|
NativePermissionDecision,
|
|
NativePrompt,
|
|
NativeServerHandle,
|
|
)
|
|
from omnigent.opencode_native_app_server import (
|
|
OpenCodeNativeServer,
|
|
build_opencode_attach_args,
|
|
client_for_state,
|
|
opencode_terminal_env,
|
|
)
|
|
from omnigent.opencode_native_bridge import read_bridge_state
|
|
from omnigent.opencode_native_client import OpenCodeClient
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
ClientFactory = Callable[[], OpenCodeClient]
|
|
|
|
# Public surface of this transport module. ``ClientFactory`` is the documented
|
|
# annotation for ``OpenCodeHttpTransport(client_factory=...)``; export it so the
|
|
# alias reads as intended public API (its only other use is a PEP 563 stringified
|
|
# annotation, which static analysis can't see as a load).
|
|
__all__ = ["ClientFactory", "OpenCodeHttpTransport", "build_prompt_payload"]
|
|
|
|
|
|
def build_prompt_payload(prompt: NativePrompt) -> dict[str, Any]:
|
|
"""
|
|
Build an OpenCode prompt request body from a :class:`NativePrompt`.
|
|
|
|
:param prompt: The normalized prompt.
|
|
:returns: A ``{"parts": [...], ...}`` body for ``POST
|
|
/session/{id}/message`` or ``/prompt_async``.
|
|
"""
|
|
parts: list[dict[str, Any]] = []
|
|
if prompt.text:
|
|
parts.append({"type": "text", "text": prompt.text})
|
|
for attachment in prompt.attachments:
|
|
part = _attachment_to_part(attachment)
|
|
if part is not None:
|
|
parts.append(part)
|
|
payload: dict[str, Any] = {"parts": parts}
|
|
if prompt.system_prompt:
|
|
payload["system"] = prompt.system_prompt
|
|
model = _split_model(prompt.model)
|
|
if model is not None:
|
|
payload["model"] = model
|
|
return payload
|
|
|
|
|
|
def _attachment_to_part(attachment: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
"""
|
|
Convert an Omnigent attachment block into an OpenCode file part.
|
|
|
|
:param attachment: An ``input_image`` / ``input_file`` content block.
|
|
:returns: A ``FilePartInput`` dict, or ``None`` when unconvertible.
|
|
"""
|
|
block_type = attachment.get("type")
|
|
if block_type == "input_image":
|
|
url = attachment.get("image_url")
|
|
if isinstance(url, str) and url:
|
|
mime = _mime_from_data_uri(url) or "image/png"
|
|
return {"type": "file", "mime": mime, "url": url}
|
|
if block_type == "input_file":
|
|
url = attachment.get("file_data") or attachment.get("url")
|
|
if isinstance(url, str) and url:
|
|
mime = _mime_from_data_uri(url) or "application/octet-stream"
|
|
part: dict[str, Any] = {"type": "file", "mime": mime, "url": url}
|
|
filename = attachment.get("filename")
|
|
if isinstance(filename, str) and filename:
|
|
part["filename"] = filename
|
|
return part
|
|
return None
|
|
|
|
|
|
def _mime_from_data_uri(uri: str) -> str | None:
|
|
"""
|
|
Extract the MIME type from a ``data:`` URI.
|
|
|
|
:param uri: A data URI, e.g. ``"data:image/png;base64,..."``.
|
|
:returns: The MIME type, or ``None``.
|
|
"""
|
|
if not uri.startswith("data:"):
|
|
return None
|
|
head = uri[len("data:") :].split(",", 1)[0]
|
|
mime = head.split(";", 1)[0]
|
|
return mime or None
|
|
|
|
|
|
def _split_model(model: str | None) -> dict[str, str] | None:
|
|
"""
|
|
Split a ``provider/model`` id into the OpenCode prompt model object.
|
|
|
|
:param model: A model id, e.g. ``"anthropic/claude-opus-4"``; ``None``
|
|
means no pin.
|
|
:returns: ``{"providerID": ..., "modelID": ...}`` or ``None``.
|
|
"""
|
|
if not model:
|
|
return None
|
|
provider, sep, model_id = model.partition("/")
|
|
if sep and provider and model_id:
|
|
return {"providerID": provider, "modelID": model_id}
|
|
return None
|
|
|
|
|
|
class OpenCodeHttpTransport:
|
|
"""
|
|
HTTP + SSE transport for opencode-native.
|
|
|
|
:param bridge_dir: Bridge dir to read server URL + auth from when no
|
|
server/client is injected (harness-side).
|
|
:param server: A running :class:`OpenCodeNativeServer` (runner-side).
|
|
:param client_factory: Optional client builder (tests).
|
|
:param directory: Workspace directory routing header.
|
|
"""
|
|
|
|
descriptor_id = "opencode-native"
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
bridge_dir: Path | None = None,
|
|
server: OpenCodeNativeServer | None = None,
|
|
client_factory: ClientFactory | None = None,
|
|
directory: str | None = None,
|
|
) -> None:
|
|
self._bridge_dir = bridge_dir
|
|
self._server = server
|
|
self._client_factory = client_factory
|
|
self._directory = directory
|
|
|
|
def _client(self) -> OpenCodeClient:
|
|
"""
|
|
Build a client from the injected factory, server, or bridge state.
|
|
|
|
:returns: A fresh :class:`OpenCodeClient` (caller closes it).
|
|
:raises RuntimeError: When no connection coordinates are available.
|
|
"""
|
|
if self._client_factory is not None:
|
|
return self._client_factory()
|
|
if self._server is not None:
|
|
return self._server.client(directory=self._directory)
|
|
if self._bridge_dir is not None:
|
|
state = read_bridge_state(self._bridge_dir)
|
|
if state is not None:
|
|
return client_for_state(
|
|
base_url=state.server_base_url,
|
|
auth_secret=state.auth_secret,
|
|
directory=self._directory or state.workspace,
|
|
)
|
|
raise RuntimeError("OpenCodeHttpTransport has no server/client/bridge state")
|
|
|
|
async def start_server(self, launch: NativeLaunchConfig) -> NativeServerHandle:
|
|
"""Start the OpenCode server and return its handle."""
|
|
if self._server is None:
|
|
self._server = OpenCodeNativeServer(
|
|
bridge_dir=self._bridge_dir or Path(launch.workspace),
|
|
workspace=Path(launch.workspace),
|
|
)
|
|
await self._server.start()
|
|
pid = self._server.process.pid if self._server.process is not None else None
|
|
return NativeServerHandle(
|
|
base_url=self._server.base_url,
|
|
env=self._server.env,
|
|
bridge_dir=self._server.bridge_dir,
|
|
process_id=pid,
|
|
)
|
|
|
|
async def stop_server(self) -> None:
|
|
"""Stop the OpenCode server, if this transport started one."""
|
|
if self._server is not None:
|
|
await self._server.close()
|
|
|
|
async def create_or_resume_session(self, launch: NativeLaunchConfig) -> str:
|
|
"""Resume the external session id, or create a new OpenCode session."""
|
|
client = self._client()
|
|
try:
|
|
if launch.external_session_id:
|
|
existing = await client.get_session(launch.external_session_id)
|
|
if existing is not None:
|
|
return existing.id
|
|
created = await client.create_session(
|
|
{"title": f"omnigent:{launch.omnigent_session_id}"}
|
|
)
|
|
return created.id
|
|
finally:
|
|
await client.aclose()
|
|
|
|
async def send_prompt(self, session_id: str, prompt: NativePrompt) -> Mapping[str, Any]:
|
|
"""Inject a prompt via ``POST /session/{id}/prompt_async``."""
|
|
client = self._client()
|
|
try:
|
|
return await client.prompt_async(session_id, build_prompt_payload(prompt))
|
|
finally:
|
|
await client.aclose()
|
|
|
|
async def abort(self, session_id: str) -> bool:
|
|
"""Abort active work via ``POST /session/{id}/abort``."""
|
|
client = self._client()
|
|
try:
|
|
return await client.abort(session_id)
|
|
finally:
|
|
await client.aclose()
|
|
|
|
async def events(self, session_id: str) -> AsyncIterator[NativeEvent]:
|
|
"""Stream native events, filtered to *session_id*."""
|
|
del session_id
|
|
client = self._client()
|
|
try:
|
|
async for event in client.events():
|
|
yield NativeEvent(
|
|
id=event.id,
|
|
type=event.type,
|
|
payload=event.properties,
|
|
raw=event.raw,
|
|
)
|
|
finally:
|
|
await client.aclose()
|
|
|
|
async def list_history(self, session_id: str) -> list[Mapping[str, Any]]:
|
|
"""Return the session's message history."""
|
|
client = self._client()
|
|
try:
|
|
return list(await client.list_messages(session_id))
|
|
finally:
|
|
await client.aclose()
|
|
|
|
async def fork(self, session_id: str, *, at_message_id: str | None = None) -> str:
|
|
"""Fork the session via ``POST /session/{id}/fork``."""
|
|
client = self._client()
|
|
try:
|
|
payload = {"messageID": at_message_id} if at_message_id else None
|
|
forked = await client.fork(session_id, payload)
|
|
return forked.id
|
|
finally:
|
|
await client.aclose()
|
|
|
|
async def reply_permission(self, decision: NativePermissionDecision) -> None:
|
|
"""Relay a permission decision via ``POST /permission/{id}/reply``."""
|
|
reply_map = {"allow_once": "once", "allow_always": "always", "reject": "reject"}
|
|
client = self._client()
|
|
try:
|
|
await client.reply_permission(
|
|
decision.request_id,
|
|
{"reply": reply_map[decision.decision], "message": decision.message or ""},
|
|
)
|
|
finally:
|
|
await client.aclose()
|
|
|
|
def build_tui_attach_command(
|
|
self, launch: NativeLaunchConfig, session_id: str
|
|
) -> tuple[list[str], Mapping[str, str]]:
|
|
"""Build the ``opencode attach`` argv + env for a TUI takeover."""
|
|
server_url = launch.server_url or (self._server.base_url if self._server else "")
|
|
argv = build_opencode_attach_args(
|
|
server_url=server_url,
|
|
workspace=launch.workspace,
|
|
session_id=session_id,
|
|
opencode_args=launch.terminal_launch_args,
|
|
)
|
|
env: Mapping[str, str] = (
|
|
opencode_terminal_env(self._server) if self._server is not None else {}
|
|
)
|
|
return argv, env
|