-
Notifications
You must be signed in to change notification settings - Fork 1
SDK 0.2.0: two-client architecture (UserClient + AgentClient) #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
AlexLiu190625
merged 16 commits into
xorbitsai:main
from
AlexLiu190625:feat/0.2.0-user-client
May 31, 2026
Merged
Changes from 13 commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
8566be7
refactor(client): extract _BaseClient for shared transport plumbing
AlexLiu190625 24d9e8d
refactor(client): rename XAgentClient to AgentClient and drop me()
AlexLiu190625 7405e61
feat(types): add UserPrincipal, Template, Agent, RotateKey + Template…
AlexLiu190625 4d93ad8
feat(client): UserClient with templates and agents namespaces
AlexLiu190625 c4308a9
test: shared fixtures and unit tests for UserClient + templates + agents
AlexLiu190625 2ddbe11
chore: bump SDK to 0.2.0 and pin the public surface
AlexLiu190625 bdfcf46
docs(readme): two-client flow, migration guide, and e2e harness
AlexLiu190625 c1135f9
fix(parse): align v1 response parsing with real backend shapes
AlexLiu190625 d9701ff
review: harden agent-create parsing, fall back on rename helpers, str…
AlexLiu190625 fa6440a
refactor(errors): add MalformedResponse for decode-shape failures
AlexLiu190625 3bc7692
harden single-object parsers against non-dict response bodies
AlexLiu190625 b30185b
fail closed when generate_runtime_key=True returns no key; fix shared…
AlexLiu190625 362acd8
strip process-history wording from e2e + README; rename grep-guard test
AlexLiu190625 347ba37
reject empty runtime key, not just None, in the fail-closed check
AlexLiu190625 a9a86ab
enforce the non-empty-key invariant at the client boundary, not the c…
AlexLiu190625 f395f80
fix(sdk): reject empty runtime keys from create responses
rogercloud File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| """The ``client.agents`` namespace exposed on ``UserClient``. | ||
|
|
||
| Provides agent lifecycle management for the personal-key authenticated | ||
| caller: list the user's agents, create new ones (structured or from a | ||
| template), and rotate the runtime API key on an existing agent. | ||
|
|
||
| ``create()`` and ``create_from_template()`` default to | ||
| ``generate_runtime_key=True`` to match the backend default; the returned | ||
| ``AgentCreateResult.runtime_full_key`` is a **one-time** payload. The SDK | ||
| deliberately does not cache it -- the only chance to read the secret is | ||
| the immediate response. Pass ``generate_runtime_key=False`` when the | ||
| caller plans to rotate later via ``rotate_key()``. | ||
| """ | ||
|
|
||
| from collections.abc import Mapping | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| from xagent_sdk.errors import MalformedResponse | ||
| from xagent_sdk.types import ( | ||
| AgentCreateResult, | ||
| AgentSummary, | ||
| RotateKeyResult, | ||
| _parse_agent_create, | ||
| _parse_agent_list, | ||
| _parse_rotate_key, | ||
| ) | ||
|
|
||
| if TYPE_CHECKING: | ||
| from xagent_sdk.user_client import UserClient | ||
|
|
||
|
|
||
| def _require_runtime_key( | ||
| result: AgentCreateResult, generate_runtime_key: bool | ||
| ) -> AgentCreateResult: | ||
| """Fail closed when a key was requested but the response carried none. | ||
|
|
||
| ``generate_runtime_key=True`` is a promise that the response includes | ||
| a one-time runtime key. If the backend omits it, returning a result | ||
| with ``runtime_full_key=None`` is dangerous: the caller is expected to | ||
| do ``AgentClient(api_key=result.runtime_full_key)``, and ``None`` there | ||
| falls back to ``XAGENT_API_KEY`` in the environment -- silently using | ||
| a *different* agent's credential. Raise instead of handing back a | ||
| keyless result that invites that fallback. | ||
| """ | ||
| if generate_runtime_key and result.runtime_full_key is None: | ||
| raise MalformedResponse( | ||
| "malformed_response", | ||
| "create requested generate_runtime_key=True but the response " | ||
| "carried no runtime key; refusing to return a keyless result " | ||
| "that would let AgentClient fall back to XAGENT_API_KEY", | ||
| http_status=None, | ||
| ) | ||
| return result | ||
|
|
||
|
|
||
| class AgentsAPI: | ||
| """The ``user_client.agents`` namespace.""" | ||
|
|
||
| def __init__(self, client: "UserClient") -> None: | ||
| self._client = client | ||
|
|
||
| def list(self) -> list[AgentSummary]: | ||
| """``GET /v1/agents`` -- list agents owned by the personal key's user. | ||
|
|
||
| Returns slim summaries (id + name + optional status). Returns an | ||
| empty list when the backend sends ``{"agents": []}`` or a non-dict | ||
| body. Standard error mapping applies (``InvalidAPIKey``, etc.). | ||
| """ | ||
| resp = self._client._request("GET", "/v1/agents") | ||
| return _parse_agent_list(resp.json()) | ||
|
|
||
| def create( | ||
| self, | ||
| *, | ||
| name: str, | ||
| instructions: str, | ||
| generate_runtime_key: bool = True, | ||
| metadata: dict[str, Any] | None = None, | ||
| ) -> AgentCreateResult: | ||
| """``POST /v1/agents`` -- structured agent creation. | ||
|
|
||
| Args: | ||
| name: Display name shown in agent pickers; backend enforces | ||
| uniqueness within the user's agent set per its own policy. | ||
| instructions: System prompt / role description. | ||
| generate_runtime_key: When ``True`` (default), the backend | ||
| provisions a fresh runtime key in the same transaction | ||
| and returns it via ``AgentCreateResult.runtime_full_key``. | ||
| Set ``False`` when the caller intends to issue the first | ||
| runtime key later via ``rotate_key()``. | ||
| metadata: Free-form correlation data the backend persists | ||
| without interpretation; analogous to ``tasks.create``'s | ||
| ``metadata`` parameter. Omitted from the wire when None. | ||
|
|
||
| Returns: | ||
| ``AgentCreateResult`` with ``agent_id``, ``name``, and | ||
| (when ``generate_runtime_key=True``) ``runtime_full_key`` + | ||
| ``runtime_key_prefix``. ``runtime_full_key`` is one-time; | ||
| persist to a secret vault and never log. | ||
|
|
||
| Raises: | ||
| InvalidInput: 422 -- backend rejected the body (e.g. empty | ||
| ``name`` or ``instructions``). | ||
| InvalidAPIKey: 401 -- personal key invalid / revoked. | ||
| MalformedResponse: ``generate_runtime_key=True`` but the | ||
| response carried no runtime key (fail closed rather than | ||
| return a keyless result). | ||
| """ | ||
| body: dict[str, Any] = { | ||
| "name": name, | ||
| "instructions": instructions, | ||
| "generate_runtime_key": generate_runtime_key, | ||
| } | ||
| if metadata is not None: | ||
| body["metadata"] = metadata | ||
| resp = self._client._request("POST", "/v1/agents", json=body) | ||
| return _require_runtime_key( | ||
| _parse_agent_create(resp.json()), generate_runtime_key | ||
| ) | ||
|
|
||
| def create_from_template( | ||
| self, | ||
| template_id: str, | ||
| *, | ||
| overrides: Mapping[str, Any] | None = None, | ||
| generate_runtime_key: bool = True, | ||
| ) -> AgentCreateResult: | ||
| """``POST /v1/agents/from-template`` -- create an agent by template. | ||
|
|
||
| The backend loads the template's ``agent_config`` and overlays | ||
| any caller-supplied fields on top before persisting. ``overrides`` | ||
| keys (``name``, ``description``, ``instructions``, | ||
| ``execution_mode``, ``models``, ``knowledge_bases``, ``skills``, | ||
| ``tool_categories``, ``suggested_prompts``) are spread into the | ||
| request body alongside ``template_id`` and ``generate_runtime_key``; | ||
| unknown keys are dropped by the backend and have no effect. | ||
|
|
||
| Args: | ||
| template_id: Template identifier from | ||
| ``templates.list()`` / ``templates.get()``. | ||
| overrides: Optional dict of fields to override on the | ||
| template (e.g. ``{"name": "My Bot"}``). Spread flat into | ||
| the wire body; ``template_id`` and ``generate_runtime_key`` | ||
| always win over collisions. | ||
| generate_runtime_key: Same semantics as ``create()``. | ||
|
|
||
| Returns: | ||
| ``AgentCreateResult``; see ``create()`` for field semantics. | ||
|
|
||
| Raises: | ||
| TemplateNotFound: 404 ``template_not_found`` -- unknown | ||
| ``template_id``. | ||
| InvalidInput: 422 -- overrides contain malformed values. | ||
| InvalidAPIKey: 401 -- personal key invalid / revoked. | ||
| MalformedResponse: ``generate_runtime_key=True`` but the | ||
| response carried no runtime key (fail closed rather than | ||
| return a keyless result). | ||
| """ | ||
| body: dict[str, Any] = { | ||
| **(dict(overrides) if overrides else {}), | ||
| "template_id": template_id, | ||
| "generate_runtime_key": generate_runtime_key, | ||
| } | ||
| resp = self._client._request("POST", "/v1/agents/from-template", json=body) | ||
| return _require_runtime_key( | ||
| _parse_agent_create(resp.json()), generate_runtime_key | ||
| ) | ||
|
|
||
| def rotate_key(self, agent_id: int) -> RotateKeyResult: | ||
| """``POST /v1/agents/{agent_id}/api-key`` -- rotate runtime key. | ||
|
|
||
| Destructive: the previous runtime key for ``agent_id`` is | ||
| revoked atomically with the new key insertion. The returned | ||
| ``full_key`` is a **one-time** payload -- existing AgentClient | ||
| instances using the old key will start receiving | ||
| ``InvalidAPIKey`` on the next request. | ||
|
|
||
| Args: | ||
| agent_id: Target agent. Must be owned by the personal key's | ||
| user. | ||
|
|
||
| Returns: | ||
| ``RotateKeyResult`` with ``full_key`` (one-time secret), | ||
| ``key_prefix`` (public-safe handle), and ``created_at``. | ||
|
|
||
| Raises: | ||
| AgentNotFound: 404 ``agent_not_found`` -- agent does not | ||
| exist or is not owned by the calling user. | ||
| InvalidAPIKey: 401 -- personal key invalid / revoked. | ||
| """ | ||
| resp = self._client._request("POST", f"/v1/agents/{agent_id}/api-key") | ||
| return _parse_rotate_key(resp.json()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| """Internal base class shared by every public SDK client. | ||
|
|
||
| The two public clients (``AgentClient`` for runtime chat tasks and | ||
| ``UserClient`` for management endpoints) only differ in which env var | ||
| provides the API key fallback and which surface methods they expose. | ||
| Everything else -- env resolution, ``HTTPClient`` ownership, the | ||
| 4xx/5xx-to-exception mapping in ``_request``, ``close()``, and the | ||
| context-manager protocol -- is identical, so it lives here. | ||
|
|
||
| Subclasses customize the key resolution by overriding two class | ||
| attributes: | ||
|
|
||
| - ``_ENV_API_KEY``: the environment variable consulted when the caller | ||
| does not pass an explicit key (``XAGENT_API_KEY`` for the runtime | ||
| client, ``XAGENT_PERSONAL_KEY`` for the user client). | ||
| - ``_API_KEY_FIELD``: the parameter name to use in the ``ValueError`` | ||
| message when the key is missing. Showing the right name keeps the | ||
| error actionable for whichever public surface raised it. | ||
| """ | ||
|
|
||
| import os | ||
| from types import TracebackType | ||
| from typing import ClassVar, Self | ||
|
|
||
| import httpx | ||
|
|
||
| from xagent_sdk._http import HTTPClient | ||
| from xagent_sdk.errors import from_response | ||
|
|
||
|
|
||
| class _BaseClient: | ||
| """Shared transport plumbing for SDK clients. | ||
|
|
||
| Not part of the public surface; subclasses are. | ||
| """ | ||
|
|
||
| _ENV_API_KEY: ClassVar[str] = "XAGENT_API_KEY" | ||
| _API_KEY_FIELD: ClassVar[str] = "api_key" | ||
|
|
||
| def __init__( | ||
| self, | ||
| api_key: str | 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: | ||
| api_key = api_key or os.environ.get(self._ENV_API_KEY) | ||
| base_url = base_url or os.environ.get("XAGENT_BASE_URL") | ||
| if not api_key: | ||
| raise ValueError( | ||
| f"{self._API_KEY_FIELD} required: " | ||
| 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" | ||
| ) | ||
|
|
||
| self._http = HTTPClient( | ||
| base_url=base_url, | ||
| api_key=api_key, | ||
| timeout=timeout, | ||
| max_connections=max_connections, | ||
| user_agent=user_agent, | ||
| transport=transport, | ||
| ) | ||
|
|
||
| def close(self) -> None: | ||
| self._http.close() | ||
|
|
||
| def __enter__(self) -> Self: | ||
| return self | ||
|
|
||
| def __exit__( | ||
| self, | ||
| exc_type: type[BaseException] | None, | ||
| exc_val: BaseException | None, | ||
| exc_tb: TracebackType | None, | ||
| ) -> None: | ||
| self.close() | ||
|
|
||
| def _request( | ||
| self, | ||
| method: str, | ||
| path: str, | ||
| *, | ||
| json: dict[str, object] | None = None, | ||
| ) -> httpx.Response: | ||
| """Send a request and map any 4xx/5xx response to an XAgentError. | ||
|
|
||
| Protected by package convention: called by API namespace classes | ||
| within ``xagent_sdk`` (TasksAPI, TemplatesAPI, AgentsAPI) but not | ||
| part of the user-facing API. | ||
|
|
||
| Transport-level failures are already wrapped in | ||
| ``XAgentTransportError`` by ``HTTPClient.request``; this helper | ||
| only adds the V1-envelope-to-exception mapping for HTTP error | ||
| responses that do have a body. | ||
| """ | ||
| resp = self._http.request(method, path, json=json) | ||
| if resp.is_error: | ||
| raise from_response(resp) | ||
| return resp |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This now fails closed for a missing runtime key, but it still lets an empty
full_keythrough.AgentClient(api_key="")is also dangerous because_BaseClientresolves keys withapi_key or os.environ.get(...), so an empty string falls back toXAGENT_API_KEYand can silently authenticate as a different agent. Please reject all empty runtime keys here, for exampleif generate_runtime_key and not result.runtime_full_key:, and cover the empty-string case for bothcreate()andcreate_from_template().