diff --git a/python/README.md b/python/README.md index ebad190..236a548 100644 --- a/python/README.md +++ b/python/README.md @@ -4,9 +4,9 @@ Python client SDK for the [xAgent](https://github.com/xorbitsai/xagent) HTTP v1 API. Lets a SaaS app authenticate as a user, mint AI agents from templates, and trigger them — all in a handful of lines. -> **Status**: 0.3.0 — early access. Adds the optional -> `xagent_sdk.cloud.WorkspaceClient` (hosted workspace surface); -> additive, nothing else changes. **Breaking change vs 0.1.0**: the SDK +> **Status**: 0.3.1 — early access. Optional +> `xagent_sdk.cloud.WorkspaceClient` (hosted workspace surface) selects a +> hosted region. **Breaking change vs 0.1.0**: the SDK > exposes two clients (``UserClient`` for management, ``AgentClient`` for > runtime) instead of a single class, and `/v1/me` returns a user > principal instead of an agent identity. See @@ -17,7 +17,7 @@ from templates, and trigger them — all in a handful of lines. Pin to a release tag — do **not** install from `main`: ```bash -pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.3.0#subdirectory=python" +pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.3.1#subdirectory=python" ``` The Python client lives under [`python/`](.) in the @@ -299,29 +299,33 @@ only. For SaaS apps on the hosted service. Constructed with a **workspace key** and manages agents/templates scoped to a workspace. Lives under `xagent_sdk.cloud` so the self-hosted package is unaffected — import it -explicitly: +explicitly. + +The hosted service is per-region; a workspace key only works against the +region that issued it. Pass the `Region` shown in your deploy snippet (or +an explicit `base_url` for a self-hosted / not-yet-listed region). The +minted runtime key runs on the same host, so reuse that `base_url` for +`AgentClient`: ```python -from xagent_sdk.cloud import WorkspaceClient +from xagent_sdk.cloud import Region, WorkspaceClient from xagent_sdk import AgentClient -# WorkspaceClient defaults base_url to the hosted endpoint; AgentClient -# does not, so give both the same base_url to run on one surface. -base_url = "https://cloud.xagent.run" +region = Region.SG # from the deploy snippet -with WorkspaceClient(workspace_key="xag_workspace_...", base_url=base_url) as ws: +with WorkspaceClient(workspace_key="xag_workspace_...", region=region) as ws: created = ws.agents.create_from_template( "support-ai-chatbot-agent", name="HR Leave Assistant" ) runtime_key = created.runtime_full_key # one-time secret -with AgentClient(api_key=runtime_key, base_url=base_url) as agent: +with AgentClient(api_key=runtime_key, base_url=region.base_url) as agent: print(agent.tasks.run(agent_id=created.agent_id, message="Hi").output) ``` | Method | Returns | Notes | |---|---|---| -| `WorkspaceClient(workspace_key, base_url, ...)` | `WorkspaceClient` | env fallback `XAGENT_WORKSPACE_KEY`; `base_url` defaults to `https://cloud.xagent.run` (override via arg / `XAGENT_BASE_URL`) | +| `WorkspaceClient(workspace_key, *, region=None, base_url=None, ...)` | `WorkspaceClient` | env fallback `XAGENT_WORKSPACE_KEY`; pass `region=Region.AU/SG` **or** `base_url=...` (not both); neither + no `XAGENT_BASE_URL` raises — no hosted default | | `ws.templates.list()` / `ws.templates.get(id)` | `list[Template]` / `TemplateDetail` | GET `/v1/workspace/templates*` | | `ws.agents.list()` | `list[AgentSummary]` | GET `/v1/workspace/agents` | | `ws.agents.create(*, name, instructions, description=None, execution_mode=None, models=None, knowledge_bases=None, skills=None, tool_categories=None, suggested_prompts=None, generate_runtime_key=True)` | `AgentCreateResult` | POST `/v1/workspace/agents` | @@ -385,7 +389,7 @@ connection pool). - **Always pin to a git tag** in production: ```bash - pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.3.0#subdirectory=python" + pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.3.1#subdirectory=python" ``` Installing from `@main` will eventually break you when the surface @@ -393,7 +397,7 @@ connection pool). required because the SDK lives in a subdirectory of the multi-language monorepo. - The User-Agent header carries the SDK version - (`xagent-sdk-python/0.3.0`) so the backend can correlate issues. + (`xagent-sdk-python/0.3.1`) so the backend can correlate issues. ## Development diff --git a/python/pyproject.toml b/python/pyproject.toml index a2944d6..11bec36 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "xagent-sdk" -version = "0.3.0" +version = "0.3.1" description = "Python client SDK for xAgent" readme = "README.md" requires-python = ">=3.11" diff --git a/python/src/xagent_sdk/_base.py b/python/src/xagent_sdk/_base.py index 4148ba3..4d18957 100644 --- a/python/src/xagent_sdk/_base.py +++ b/python/src/xagent_sdk/_base.py @@ -63,6 +63,10 @@ class _BaseClient: _ENV_API_KEY: ClassVar[str] = "XAGENT_API_KEY" _API_KEY_FIELD: ClassVar[str] = "api_key" _DEFAULT_BASE_URL: ClassVar[str | None] = None + # How a subclass tells the caller to supply a base URL when none could + # be resolved. Overridden where the public way to set it differs (e.g. + # the workspace client's ``region=``). + _BASE_URL_HINT: ClassVar[str] = "pass base_url=... or set XAGENT_BASE_URL" def __init__( self, @@ -85,9 +89,7 @@ def __init__( f"pass {self._API_KEY_FIELD}=... or set {self._ENV_API_KEY}" ) if not base_url: - raise ValueError( - "base_url required: pass base_url=... or set XAGENT_BASE_URL" - ) + raise ValueError(f"base_url required: {self._BASE_URL_HINT}") self._http = HTTPClient( base_url=base_url, diff --git a/python/src/xagent_sdk/_version.py b/python/src/xagent_sdk/_version.py index 493f741..260c070 100644 --- a/python/src/xagent_sdk/_version.py +++ b/python/src/xagent_sdk/_version.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/python/src/xagent_sdk/cloud/__init__.py b/python/src/xagent_sdk/cloud/__init__.py index 1a7a022..884c705 100644 --- a/python/src/xagent_sdk/cloud/__init__.py +++ b/python/src/xagent_sdk/cloud/__init__.py @@ -1,3 +1,4 @@ +from xagent_sdk.cloud.region import Region from xagent_sdk.cloud.workspace_client import WorkspaceClient -__all__ = ["WorkspaceClient"] +__all__ = ["Region", "WorkspaceClient"] diff --git a/python/src/xagent_sdk/cloud/region.py b/python/src/xagent_sdk/cloud/region.py new file mode 100644 index 0000000..e302ad3 --- /dev/null +++ b/python/src/xagent_sdk/cloud/region.py @@ -0,0 +1,31 @@ +"""Hosted regions for the workspace surface. + +The hosted service runs as independent per-region deployments, each with +its own database. A workspace key is only valid against the region that +issued it, so the client must target that region's host. Pass the +``Region`` shown in the deploy snippet to ``WorkspaceClient`` instead of +hardcoding a URL; for a self-hosted or not-yet-listed region, pass an +explicit ``base_url`` instead. +""" + +from enum import StrEnum + + +class Region(StrEnum): + """A hosted region. Its value is the short region code (``"au"`` / + ``"sg"``); ``base_url`` gives the region's API host -- reuse it for the + ``AgentClient`` that runs an agent minted in this region. + """ + + AU = "au" + SG = "sg" + + @property + def base_url(self) -> str: + return _REGION_BASE_URL[self] + + +_REGION_BASE_URL = { + Region.AU: "https://au.cloud.xagent.co", + Region.SG: "https://sg.cloud.xagent.co", +} diff --git a/python/src/xagent_sdk/cloud/workspace_client.py b/python/src/xagent_sdk/cloud/workspace_client.py index 9693c35..67dd2c9 100644 --- a/python/src/xagent_sdk/cloud/workspace_client.py +++ b/python/src/xagent_sdk/cloud/workspace_client.py @@ -3,6 +3,7 @@ from xagent_sdk._base import _BaseClient from xagent_sdk.cloud._agents import WorkspaceAgentsAPI from xagent_sdk.cloud._templates import WorkspaceTemplatesAPI +from xagent_sdk.cloud.region import Region class WorkspaceClient(_BaseClient): @@ -21,8 +22,12 @@ class WorkspaceClient(_BaseClient): A separate variable from ``XAGENT_API_KEY`` / ``XAGENT_PERSONAL_KEY`` so the clients can coexist in one process. An explicitly empty key raises rather than falling back to the environment. - - ``base_url``: explicit keyword, else ``XAGENT_BASE_URL``, else the - hosted default ``https://cloud.xagent.run``. + - Target host: pass **either** ``region`` (the ``Region`` shown in the + deploy snippet -- the hosted service is per-region and a workspace + key only works against the region that issued it) **or** an explicit + ``base_url`` (for a self-hosted or not-yet-listed region), but not + both. With neither, ``XAGENT_BASE_URL`` is used; if that is unset the + constructor raises rather than guessing a host. Missing values at construction raise ``ValueError`` instead of deferring failure to the first request. ``transport`` accepts any @@ -31,18 +36,23 @@ class WorkspaceClient(_BaseClient): _ENV_API_KEY = "XAGENT_WORKSPACE_KEY" _API_KEY_FIELD = "workspace_key" - _DEFAULT_BASE_URL = "https://cloud.xagent.run" + _BASE_URL_HINT = "pass region=... (or base_url=...) or set XAGENT_BASE_URL" def __init__( self, workspace_key: str | None = None, - base_url: str | None = None, *, + region: Region | None = None, + base_url: str | None = None, timeout: float = 30.0, max_connections: int = 10, user_agent: str | None = None, transport: httpx.BaseTransport | None = None, ) -> None: + if region is not None: + if base_url is not None: + raise ValueError("pass region or base_url, not both") + base_url = region.base_url super().__init__( api_key=workspace_key, base_url=base_url, diff --git a/python/tests/unit/cloud/test_cloud_surface.py b/python/tests/unit/cloud/test_cloud_surface.py index 183f8b0..2ae9eff 100644 --- a/python/tests/unit/cloud/test_cloud_surface.py +++ b/python/tests/unit/cloud/test_cloud_surface.py @@ -1,24 +1,26 @@ """Pin for the cloud submodule public surface. -The cloud submodule exposes exactly ``WorkspaceClient`` and is not +The cloud submodule exposes ``WorkspaceClient`` and ``Region`` and is not re-exported from the top-level package: importing ``xagent_sdk`` must not -pull in cloud or surface ``WorkspaceClient`` at the top level. +pull in cloud or surface these at the top level. """ import xagent_sdk import xagent_sdk.cloud -def test_cloud_all_is_exactly_workspace_client() -> None: - assert set(xagent_sdk.cloud.__all__) == {"WorkspaceClient"} +def test_cloud_all_is_exact_set() -> None: + assert set(xagent_sdk.cloud.__all__) == {"WorkspaceClient", "Region"} -def test_workspace_client_resolves_from_cloud() -> None: - from xagent_sdk.cloud import WorkspaceClient +def test_cloud_names_resolve() -> None: + from xagent_sdk.cloud import Region, WorkspaceClient assert WorkspaceClient.__name__ == "WorkspaceClient" + assert {r.value for r in Region} == {"au", "sg"} -def test_workspace_client_not_on_top_level() -> None: - assert not hasattr(xagent_sdk, "WorkspaceClient") - assert "WorkspaceClient" not in xagent_sdk.__all__ +def test_cloud_names_not_on_top_level() -> None: + for name in ("WorkspaceClient", "Region"): + assert not hasattr(xagent_sdk, name) + assert name not in xagent_sdk.__all__ diff --git a/python/tests/unit/cloud/test_workspace_client.py b/python/tests/unit/cloud/test_workspace_client.py index 1e33821..86ecb12 100644 --- a/python/tests/unit/cloud/test_workspace_client.py +++ b/python/tests/unit/cloud/test_workspace_client.py @@ -3,7 +3,7 @@ import httpx import pytest -from xagent_sdk.cloud import WorkspaceClient +from xagent_sdk.cloud import Region, WorkspaceClient def _ok(req: httpx.Request) -> httpx.Response: @@ -42,15 +42,40 @@ def test_env_isolation(self, monkeypatch: pytest.MonkeyPatch) -> None: with pytest.raises(ValueError, match="workspace_key"): WorkspaceClient() - def test_default_base_url(self, monkeypatch: pytest.MonkeyPatch) -> None: - # No explicit base_url and no env -> the hosted default applies. - monkeypatch.delenv("XAGENT_BASE_URL", raising=False) + def test_region_base_url_property(self) -> None: + assert Region.SG.base_url == "https://sg.cloud.xagent.co" + assert Region.AU.base_url == "https://au.cloud.xagent.co" + + def test_region_resolves_to_url(self) -> None: + c = WorkspaceClient( + workspace_key="xag_workspace_p_s", + region=Region.SG, + transport=httpx.MockTransport(_ok), + ) + assert str(c._http._client.base_url) == "https://sg.cloud.xagent.co" + c.close() c = WorkspaceClient( - workspace_key="xag_workspace_p_s", transport=httpx.MockTransport(_ok) + workspace_key="xag_workspace_p_s", + region=Region.AU, + transport=httpx.MockTransport(_ok), ) - assert str(c._http._client.base_url) == "https://cloud.xagent.run" + assert str(c._http._client.base_url) == "https://au.cloud.xagent.co" c.close() + def test_region_and_base_url_conflict(self) -> None: + with pytest.raises(ValueError, match="region or base_url"): + WorkspaceClient( + workspace_key="xag_workspace_p_s", + region=Region.SG, + base_url="https://x", + ) + + def test_no_region_no_base_url_no_env_raises(self) -> None: + # No region, no base_url, no XAGENT_BASE_URL -> fail fast. There is + # no hosted default to guess (the service is per-region). + with pytest.raises(ValueError, match="base_url"): + WorkspaceClient(workspace_key="xag_workspace_p_s") + def test_empty_key_no_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: # An explicit empty key must raise, never resolve to the env value. monkeypatch.setenv("XAGENT_WORKSPACE_KEY", "xag_workspace_env_sec") @@ -61,11 +86,10 @@ def test_missing_key(self) -> None: with pytest.raises(ValueError, match="workspace_key"): WorkspaceClient(base_url="https://x") - def test_empty_env_base_url_does_not_use_hosted_default( + def test_empty_env_base_url_fails_fast( self, monkeypatch: pytest.MonkeyPatch ) -> None: - # A broken XAGENT_BASE_URL="" must fail fast, not silently route to - # the hosted default https://cloud.xagent.run. + # A broken XAGENT_BASE_URL="" must fail fast, not be swallowed. monkeypatch.setenv("XAGENT_BASE_URL", "") with pytest.raises(ValueError, match="base_url"): WorkspaceClient(workspace_key="xag_workspace_p_s") diff --git a/python/tests/unit/test_public_surface.py b/python/tests/unit/test_public_surface.py index 42edaf8..d5cbfc3 100644 --- a/python/tests/unit/test_public_surface.py +++ b/python/tests/unit/test_public_surface.py @@ -85,4 +85,4 @@ def test_meresponse_name_not_exposed() -> None: def test_version_matches_pyproject() -> None: # The version string the SDK announces (also in the User-Agent # header) must match the packaged release. - assert xagent_sdk.__version__ == "0.3.0" + assert xagent_sdk.__version__ == "0.3.1"