"""OmnigentClient — the top-level client tying all namespaces together.""" from __future__ import annotations from collections.abc import Callable from typing import Any, Literal, overload import httpx from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN from ._files import FilesNamespace from ._query import QueryResult, QueryStream from ._responses import ResponsesNamespace from ._session import Session from ._sessions import SessionsNamespace from ._sessions_chat import SessionsChat, ToolCallable from ._tool_handler import StreamHooks, ToolHandler class OmnigentClient: """Typed Python client for the omnigent server API. One-shot:: async with OmnigentClient(base_url="http://localhost:8080") as client: result = await client.query(model="archer", input="hello") print(result.text) # the assistant's reply print(result.files) # any files the agent produced Streaming:: stream = await client.query(model="archer", input="hi", stream=True) async for chunk in stream: print(chunk, end="", flush=True) print(stream.files) # populated after the stream ends Multi-turn conversation:: session = client.session(model="archer") await session.query("hello") await session.query("what did I just say?") For access to raw events or semantic blocks (tool-call display, reasoning, lifecycle), drop to :attr:`responses` or :class:`BlockStream`. :param base_url: Server base URL, e.g. ``"http://localhost:8080"``. :param headers: Extra headers sent on every request (e.g. auth). :param auth: Optional ``httpx.Auth`` for per-request authentication. When set, the auth handler runs on every request, allowing transparent token refresh for OAuth flows. ``None`` (default) relies on static ``headers``. :param timeout: Default timeout for HTTP requests in seconds. """ def __init__( self, base_url: str, *, headers: dict[str, str] | None = None, auth: httpx.Auth | None = None, # Public SDK API surface. Removing would be a breaking # change for downstream consumers; leaving it # accepted-but-ignored preserves compatibility while the # SSE client internally uses a fixed 600s read timeout # (tool calls can legitimately hold the stream open for # minutes). timeout: float = 30.0, ) -> None: self._base_url = base_url.rstrip("/") # Long read timeout for SSE streams (tool execution can # pause the stream for minutes). sse_timeout = httpx.Timeout( connect=30.0, read=600.0, write=30.0, pool=30.0, ) # Announce this as a first-party non-browser client via the sentinel # Origin. The server's require_trusted_origin CSRF guard on the # multipart routes (POST /v1/sessions bundle create, file upload) # requires a trusted Origin; the SDK sends none of its own, so the # sentinel is what lets it through. Caller-supplied headers win on # conflict (so an explicit Origin override is still honored). default_headers = {"Origin": OMNIGENT_INTERNAL_WS_ORIGIN} if headers: default_headers.update(headers) self._http = httpx.AsyncClient( headers=default_headers, auth=auth, timeout=sse_timeout, ) self.sessions = SessionsNamespace(self._http, self._base_url) self.files = FilesNamespace(self._http, self._base_url) self.responses = ResponsesNamespace(self._http, self._base_url) def session( self, model: str, *, tool_handler: ToolHandler | None = None, hooks: StreamHooks | None = None, ) -> Session: """Create a conversation session. A session tracks ``previous_response_id`` automatically. ``send()`` auto-steers if a response is in progress, or starts a new turn if the response is terminal. :param model: Agent name. :param tool_handler: Optional client-side tool execution config. :param hooks: Optional lifecycle hooks. :returns: A new :class:`Session`. """ return Session( client=self, model=model, tool_handler=tool_handler, hooks=hooks, ) @overload async def query( self, *, model: str, input: str | list[dict[str, object]], tools: list[Callable[..., Any]] | None = ..., tool_handler: ToolHandler | None = ..., files: list[str] | None = ..., reasoning: dict[str, str] | None = ..., model_override: str | None = ..., stream: Literal[False] = ..., ) -> QueryResult: ... @overload async def query( self, *, model: str, input: str | list[dict[str, object]], tools: list[Callable[..., Any]] | None = ..., tool_handler: ToolHandler | None = ..., files: list[str] | None = ..., reasoning: dict[str, str] | None = ..., model_override: str | None = ..., stream: Literal[True], ) -> QueryStream: ... async def query( self, *, model: str, input: str | list[dict[str, object]], tools: list[Callable[..., Any]] | None = None, tool_handler: ToolHandler | None = None, files: list[str] | None = None, reasoning: dict[str, str] | None = None, model_override: str | None = None, stream: bool = False, ) -> QueryResult | QueryStream: """One-shot invocation: send a prompt, get text (plus any files) back. Non-streaming (default) returns a :class:`QueryResult`:: result = await client.query(model="archer", input="hi") print(result.text) for f in result.files: await client.files.for_session("").download( f.id, f"./out/{f.filename}" ) Streaming returns a :class:`QueryStream`:: stream = await client.query(model="archer", input="hi", stream=True) async for chunk in stream: print(chunk, end="", flush=True) # After iteration, stream.files holds the produced files. With client-side tools, pass ``@tool``-decorated functions:: from omnigent_client import tool @tool def get_time() -> str: '''Return the current time.''' return datetime.now().isoformat() result = await client.query( model="archer", input="what time?", tools=[get_time], ) Creates a single-turn session internally. For multi-turn conversations, call :meth:`session` and use its ``query()``. :param model: Agent name, e.g. ``"archer"``. :param input: User text or a list of content-block dicts. :param tools: List of ``@tool``-decorated Python functions the agent may call. Mutually exclusive with ``tool_handler``. :param tool_handler: Low-level escape hatch — a pre-built :class:`ToolHandler` with custom schemas/dispatch. Most callers should use ``tools=`` instead. :param files: Optional list of local file paths to attach. :param reasoning: Optional Responses API reasoning config, e.g. {"effort": "high"}. :param model_override: Optional per-request LLM model override (e.g. ``"openai/gpt-5.4-mini"``). Shadows the spec model for this one-shot call; mirrors :meth:`Session.set_model_override`. :param stream: If True, return a :class:`QueryStream`. If False (default), return a :class:`QueryResult`. :returns: :class:`QueryResult` (``stream=False``) or :class:`QueryStream` (``stream=True``). :raises ValueError: If both ``tools`` and ``tool_handler`` are provided. """ handler = _resolve_tool_handler(tools=tools, tool_handler=tool_handler) session = self.session(model=model, tool_handler=handler) effort = reasoning.get("effort") if reasoning is not None else None if effort is not None: session.set_reasoning_effort(effort) if model_override is not None: session.set_model_override(model_override) if stream: return await session.query(input, files=files, stream=True) return await session.query(input, files=files) async def sessions_chat( self, bundle: bytes, *, filename: str = "agent.tar.gz", tool_callables: dict[str, ToolCallable] | None = None, hooks: StreamHooks | None = None, ) -> SessionsChat: """Create a sessions-API-native chat helper bound to a new session. Counterpart to :meth:`session` but built on ``/v1/sessions`` rather than ``/v1/responses``. Use this for new code; the legacy :meth:`session` is preserved for in-flight migrations. :param bundle: Gzipped agent tarball bytes uploaded through multipart ``POST /v1/sessions``. :param filename: Filename for the multipart upload, e.g. ``"agent.tar.gz"``. :param tool_callables: Optional mapping from tool name to an executable callable (sync or async) for client-side tool execution. Validated against the agent's spec-declared tools at stream-start time (the first ``send()`` / ``query()`` / ``stream()`` call), not at construction. See :class:`SessionsChat` for the validation rules. :param hooks: Optional lifecycle hooks fired from sessions stream events. :returns: A :class:`SessionsChat` ready for use. :raises OmnigentError: If session creation fails. """ return await SessionsChat.create( namespace=self.sessions, bundle=bundle, filename=filename, files_namespace=self.files, tool_callables=tool_callables, agent_tools_getter=self._fetch_agent_tools, hooks=hooks, ) async def _fetch_agent_tools( self, agent_id: str, session_id: str | None = None ) -> list[dict[str, Any]]: """ Fetch the spec-declared tool entries for an agent. Used as the ``agent_tools_getter`` injection for :class:`SessionsChat`. Reads the tool list off the :class:`Agent` returned by ``GET /api/agents/{agent_id}``. The server's :class:`AgentObject` carries a ``tools`` list where each entry has a ``name`` and a ``runtime`` discriminator (``"server"`` or ``"client"``). When that field is not yet present, the SDK's :class:`Agent` dataclass simply lacks the field and this returns ``[]`` — which means validation succeeds for any caller that doesn't pass ``tool_callables``, and fails loud (with a clear "extra callable" message) for any caller that does. That is the correct degraded behavior: in F1's absence we cannot verify the spec, but we will never silently accept a broken setup. :param agent_id: The agent's durable identifier, e.g. ``"ag_abc123"``. :returns: List of tool-entry dicts with at least ``name`` and (post-F1) ``runtime`` keys. Empty if the agent declares no tools or the server response shape predates F1. :raises OmnigentError: If the agents endpoint returns a non-2xx (e.g. 404). """ if session_id is None: return [] # No session context — cannot resolve agent tools path = f"{self._base_url}/v1/sessions/{session_id}/agent" resp = await self._http.get(path) if resp.status_code != 200: return [] agent_data = resp.json() tools = agent_data.get("tools") if isinstance(tools, list): return [t for t in tools if isinstance(t, dict)] return [] async def close(self) -> None: """Close the underlying HTTP client.""" await self._http.aclose() async def __aenter__(self) -> OmnigentClient: return self async def __aexit__(self, *exc: object) -> None: await self.close() def _resolve_tool_handler( *, tools: list[Callable[..., Any]] | None, tool_handler: ToolHandler | None, ) -> ToolHandler | None: """Pick one of ``tools=`` or ``tool_handler=``; reject both. :param tools: High-level list of ``@tool``-decorated functions. :param tool_handler: Low-level pre-built handler. :returns: The handler to use, or ``None`` if neither was given. :raises ValueError: If both were provided. """ if tools is not None and tool_handler is not None: raise ValueError( "Pass either `tools=[...]` or `tool_handler=...`, not both. " "`tools=` is the high-level API (auto-builds a handler from " "@tool-decorated functions); `tool_handler=` is the low-level " "escape hatch." ) if tools is not None: # Local import keeps the dep inside the tools subpackage. from .tools import build_tool_handler return build_tool_handler(tools) return tool_handler