Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -385,15 +389,15 @@ 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
evolves on the 0.x track. The `#subdirectory=python` fragment is
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

Expand Down
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 5 additions & 3 deletions python/src/xagent_sdk/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion python/src/xagent_sdk/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.3.0"
__version__ = "0.3.1"
3 changes: 2 additions & 1 deletion python/src/xagent_sdk/cloud/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from xagent_sdk.cloud.region import Region
from xagent_sdk.cloud.workspace_client import WorkspaceClient

__all__ = ["WorkspaceClient"]
__all__ = ["Region", "WorkspaceClient"]
31 changes: 31 additions & 0 deletions python/src/xagent_sdk/cloud/region.py
Original file line number Diff line number Diff line change
@@ -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",
}
18 changes: 14 additions & 4 deletions python/src/xagent_sdk/cloud/workspace_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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,
Expand Down
20 changes: 11 additions & 9 deletions python/tests/unit/cloud/test_cloud_surface.py
Original file line number Diff line number Diff line change
@@ -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__
42 changes: 33 additions & 9 deletions python/tests/unit/cloud/test_workspace_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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")
2 changes: 1 addition & 1 deletion python/tests/unit/test_public_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading