diff --git a/dashscope/agentstudio/__init__.py b/dashscope/agentstudio/__init__.py new file mode 100644 index 0000000..47727d5 --- /dev/null +++ b/dashscope/agentstudio/__init__.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AgentStudio sub-SDK for the dashscope Python client. + +The ``agentstudio`` package wraps the AgentStudio managed-agent HTTP +API. All resource classes are accessed through a client instance:: + + from dashscope.agentstudio import Client + from dashscope.agentstudio.types import ( + user_message, user_tool_result, + ) + +Example +------- +>>> from dashscope.agentstudio import Client +>>> from dashscope.agentstudio.types import user_message +>>> client = Client(api_key="sk-xxx") +>>> agent = client.agents.create(name="demo", model="qwen-plus") +>>> session = client.sessions.create(agent=agent.id) +>>> client.sessions.events.send(session.id, [user_message("Hello!")]) +>>> with client.sessions.events.stream(session.id) as stream: +... for event in stream: +... print(event.type, event.to_dict()) +... if event.type == "session_status": +... break +""" + +# yapf: disable + +from .client import Client, AsyncClient +from .transport import APIResponse, RequestOptions +from . import types +from .exceptions import ( + AgentStudioError, + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + ConflictError, + InternalServerError, + InvalidRequestError, + NotFoundError, + OverloadedError, + PermissionDeniedError, + RateLimitError, + StreamClosedError, + StreamError, +) +from .pagination import ( + AsyncCursorPage, + CursorPage, +) +from .types import ( + Message, + ServerEvent, + user_define_outcome, + user_interrupt, + user_message, + user_tool_confirmation, + user_custom_tool_result, + user_tool_result, +) +from .types.params import ( + AgentCreateParams, + AgentUpdateParams, + SessionCreateParams, + SessionUpdateParams, + EnvironmentCreateParams, + EnvironmentUpdateParams, + SessionEventSendParams, +) + +__all__ = [ + # client + "Client", + "AsyncClient", + # base types + "RequestOptions", + "APIResponse", + # exceptions + "AgentStudioError", + "APIConnectionError", + "APIStatusError", + "APITimeoutError", + "AuthenticationError", + "ConflictError", + "InternalServerError", + "InvalidRequestError", + "NotFoundError", + "OverloadedError", + "PermissionDeniedError", + "RateLimitError", + "StreamError", + "StreamClosedError", + # pagination + "CursorPage", + "AsyncCursorPage", + # unified message type + "Message", + "ServerEvent", + # client event helpers (re-exported for convenience) + "user_message", + "user_interrupt", + "user_tool_confirmation", + "user_custom_tool_result", + "user_tool_result", + "user_define_outcome", + # typing module + "types", + # params (commonly used request-body types) + "AgentCreateParams", + "AgentUpdateParams", + "SessionCreateParams", + "SessionUpdateParams", + "EnvironmentCreateParams", + "EnvironmentUpdateParams", + "SessionEventSendParams", +] diff --git a/dashscope/agentstudio/client.py b/dashscope/agentstudio/client.py new file mode 100644 index 0000000..ce22cc2 --- /dev/null +++ b/dashscope/agentstudio/client.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AgentStudio client classes.""" + +from __future__ import annotations + +import os +from typing import Any, Optional, Tuple, Union + +import httpx + +from dashscope.agentstudio.resources.agents import Agents, AsyncAgents +from dashscope.agentstudio.resources.environments import ( + Environments, + AsyncEnvironments, +) +from dashscope.agentstudio.resources.files import Files, AsyncFiles +from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions +from dashscope.agentstudio.resources.skills import Skills, AsyncSkills +from dashscope.agentstudio.resources.vaults import Vaults, AsyncVaults +from dashscope.agentstudio.constants import ( + AGENTSTUDIO_BASE_URL_TEMPLATE, + AGENTSTUDIO_DEFAULT_REGION, + AGENTSTUDIO_DEFAULT_TIMEOUT, + AGENTSTUDIO_MAX_RETRIES, +) +from dashscope.agentstudio.transport import SyncTransport, AsyncTransport +from dashscope.common.api_key import get_default_api_key + + +def _resolve_base_url( + explicit_url: Optional[str], + workspace: Optional[str], + region: Optional[str] = None, +) -> str: + """Resolve the base URL. + + Priority: + 1. explicit base_url parameter (full URL) + 2. DASHSCOPE_AGENTSTUDIO_URL / AGENTSTUDIO_URL env + 3. Build from workspace + region template + """ + if explicit_url: + return explicit_url + env_url = os.environ.get("DASHSCOPE_AGENTSTUDIO_URL") or os.environ.get( + "AGENTSTUDIO_URL", + ) + if env_url: + return env_url + # Build from workspace + region + ws = workspace or os.environ.get("DASHSCOPE_WORKSPACE") + if not ws: + raise ValueError( + "workspace is required when base_url is not provided " + "(pass workspace='ws_xxx' or set DASHSCOPE_WORKSPACE env var " + "or use base_url=... to override)", + ) + rgn = region or AGENTSTUDIO_DEFAULT_REGION + return AGENTSTUDIO_BASE_URL_TEMPLATE.format( + workspace=ws, + region=rgn, + ) + + +def _user_agent(base_url: str) -> str: + try: + from dashscope.version import __version__ + except Exception: + __version__ = "0.0.0" + return f"dashscope-agentstudio-python/{__version__} (+{base_url})" + + +class Client: + """Synchronous AgentStudio client. + + Usage:: + + from dashscope.agentstudio import Client + + client = Client(api_key="sk-xxx", workspace="my-ws") + agent = client.agents.create(name="demo", model="qwen-max") + session = client.sessions.create(agent=agent.id) + with client.sessions.events.stream(session.id) as stream: + for event in stream: + print(event.type) + """ + + def __init__( + self, + *, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + base_url: Optional[str] = None, + uid: Optional[str] = None, + timeout: Optional[ + Union[float, httpx.Timeout, Tuple[float, float]] + ] = None, + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.Client] = None, + ) -> None: + resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") + resolved_base = _resolve_base_url( + base_url, + resolved_workspace, + region, + ) + self.transport = SyncTransport( + base_url=resolved_base, + api_key=api_key or get_default_api_key(), + workspace=resolved_workspace, + uid=uid or os.environ.get("DASHSCOPE_UID"), + user_agent=_user_agent(resolved_base), + timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + max_retries=max_retries, + http_client=http_client, + ) + self.agents = Agents(self) + self.sessions = Sessions(self) + self.environments = Environments(self) + self.files = Files(self) + self.skills = Skills(self) + self.vaults = Vaults(self) + + def close(self) -> None: + self.transport.close() + + def __enter__(self) -> "Client": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def __del__(self) -> None: + try: + self.close() + except Exception: + pass + + +class AsyncClient: + """Asynchronous AgentStudio client. + + Usage:: + + from dashscope.agentstudio import AsyncClient + + client = AsyncClient(api_key="sk-xxx", workspace="my-ws") + agent = await client.agents.create( + name="demo", model="qwen-max", + ) + session = await client.sessions.create(agent=agent.id) + async with client.sessions.events.stream(session.id) as stream: + async for event in stream: + print(event.type) + """ + + def __init__( + self, + *, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + base_url: Optional[str] = None, + uid: Optional[str] = None, + timeout: Optional[ + Union[float, httpx.Timeout, Tuple[float, float]] + ] = None, + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.AsyncClient] = None, + ) -> None: + resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") + resolved_base = _resolve_base_url( + base_url, + resolved_workspace, + region, + ) + self.transport = AsyncTransport( + base_url=resolved_base, + api_key=api_key or get_default_api_key(), + workspace=resolved_workspace, + uid=uid or os.environ.get("DASHSCOPE_UID"), + user_agent=_user_agent(resolved_base), + timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + max_retries=max_retries, + http_client=http_client, + ) + self.agents = AsyncAgents(self) + self.sessions = AsyncSessions(self) + self.environments = AsyncEnvironments(self) + self.files = AsyncFiles(self) + self.skills = AsyncSkills(self) + self.vaults = AsyncVaults(self) + + async def aclose(self) -> None: + await self.transport.aclose() + + async def __aenter__(self) -> "AsyncClient": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.aclose() + + def __del__(self) -> None: + try: + self.transport.close() + except Exception: + pass diff --git a/dashscope/agentstudio/constants.py b/dashscope/agentstudio/constants.py new file mode 100644 index 0000000..f589155 --- /dev/null +++ b/dashscope/agentstudio/constants.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AgentStudio wire-protocol and configuration constants.""" + +import enum +import sys + +import httpx + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + + class StrEnum(str, enum.Enum): # type: ignore[no-redef] + """Minimal StrEnum shim for Python < 3.11.""" + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +_HOST = "https://{workspace}.{region}.maas.aliyuncs.com" +AGENTSTUDIO_BASE_URL_TEMPLATE = _HOST + "/api/v1/agentstudio" +AGENTSTUDIO_DEFAULT_REGION = "cn-beijing" +AGENTSTUDIO_DEFAULT_TIMEOUT = httpx.Timeout(600.0, connect=10.0) +AGENTSTUDIO_MAX_RETRIES = 2 + + +# --------------------------------------------------------------------------- +# Wire-protocol enums +# --------------------------------------------------------------------------- + + +class SSEEventType(StrEnum): + """Server-sent event types (the value of ``event.type`` in SSE payloads). + + Client-sendable: MESSAGE, INTERRUPT, TOOL_CONFIRMATION, + FUNCTION_CALL_OUTPUT, TOOL_CALL_OUTPUT, DEFINE_OUTCOME. + Server-emitted: all types. + """ + + # Client-sendable + MESSAGE = "message" + INTERRUPT = "interrupt" + TOOL_CONFIRMATION = "tool_confirmation" + FUNCTION_CALL_OUTPUT = "function_call_output" + TOOL_CALL_OUTPUT = "tool_call_output" + DEFINE_OUTCOME = "define_outcome" + + # Server-emitted + FUNCTION_CALL = "function_call" + TOOL_CALL = "tool_call" + REASONING = "reasoning" + MCP_CALL = "mcp_call" + MCP_CALL_OUTPUT = "mcp_call_output" + THREAD_MESSAGE_SENT = "thread_message_sent" + THREAD_MESSAGE_RECEIVED = "thread_message_received" + THREAD_CONTEXT_COMPACTED = "thread_context_compacted" + SESSION_STATUS = "session_status" + ERROR = "error" + SESSION_UPDATED = "session_updated" + THREAD_CREATED = "thread_created" + THREAD_STATUS = "thread_status" + MODEL_REQUEST_START = "model_request_start" + MODEL_REQUEST_END = "model_request_end" + OUTCOME_EVALUATION = "outcome_evaluation" + + +class MessageRole(StrEnum): + """Roles used in message/event payloads.""" + + USER = "user" + ASSISTANT = "assistant" + TOOL = "tool" + + +class BlockType(StrEnum): + """Content block types (``block.type`` values).""" + + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + DATA = "data" + FILE = "file" + REFUSAL = "refusal" + ERROR = "error" + + +class SessionStatus(StrEnum): + """Session run-status values (``session_status``).""" + + IDLE = "idle" + RUNNING = "running" + RESCHEDULING = "rescheduling" + TERMINATED = "terminated" diff --git a/dashscope/agentstudio/exceptions.py b/dashscope/agentstudio/exceptions.py new file mode 100644 index 0000000..124ab5e --- /dev/null +++ b/dashscope/agentstudio/exceptions.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Exception hierarchy for the AgentStudio SDK. + +The AgentStudio service returns errors in the canonical CMA shape:: + + { + "type": "error", + "error": {"code": "invalid_request_error", "message": "..."}, + "request_id": "req_..." + } + +The pre-release backend currently emits ``error_code``/``error_message`` +instead of nested ``error.{code,message}``. We accept both shapes and +normalize to the documented form. The compatibility branch is marked +with ``# TODO(bma-fix)`` so we can remove it once the backend aligns. +""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Optional + +from dashscope.common.error import DashScopeException + + +class AgentStudioError(DashScopeException): + """Base exception for all AgentStudio SDK errors. + + Attributes + ---------- + code: str + Machine-readable error code (e.g. ``invalid_request_error``). + message: str + Human-readable error description from the server. + request_id: Optional[str] + Correlation identifier for log lookups (``req_``). + status_code: Optional[int] + HTTP status code if the error originated from a HTTP response. + raw: Optional[Mapping[str, Any]] + Original response payload for debugging. + """ + + code: str = "agentstudio_error" + + def __init__( + self, + message: str, + *, + code: Optional[str] = None, + request_id: Optional[str] = None, + status_code: Optional[int] = None, + raw: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__(message) + if code is not None: + self.code = code + self.message = message + self.request_id = request_id + self.status_code = status_code + self.raw = raw + + def __repr__(self) -> str: # pragma: no cover - debug helper + return ( + f"{type(self).__name__}(code={self.code!r}, " + f"message={self.message!r}, request_id={self.request_id!r}, " + f"status_code={self.status_code!r})" + ) + + +# --------------------------------------------------------------------------- +# Connection / transport layer errors (no HTTP response received) +# --------------------------------------------------------------------------- + + +class APIConnectionError(AgentStudioError): + """Raised when the HTTP request fails before a response is read.""" + + code = "api_connection_error" + + +class APITimeoutError(APIConnectionError): + """Raised on connect / read timeouts.""" + + code = "api_timeout_error" + + +# --------------------------------------------------------------------------- +# Server-side errors (HTTP response received) +# --------------------------------------------------------------------------- + + +class APIStatusError(AgentStudioError): + """Raised when the server returns a non-2xx status.""" + + code = "api_status_error" + + +class InvalidRequestError(APIStatusError): + code = "invalid_request_error" + + +class AuthenticationError(APIStatusError): + code = "authentication_error" + + +class PermissionDeniedError(APIStatusError): + code = "permission_denied_error" + + +class NotFoundError(APIStatusError): + code = "not_found_error" + + +class ConflictError(APIStatusError): + code = "conflict_error" + + +class RateLimitError(APIStatusError): + code = "rate_limit_error" + + +class OverloadedError(APIStatusError): + code = "overloaded_error" + + +class InternalServerError(APIStatusError): + code = "api_error" + + +# --------------------------------------------------------------------------- +# Streaming errors +# --------------------------------------------------------------------------- + + +class StreamError(AgentStudioError): + """Raised when an SSE stream encounters a fatal protocol error.""" + + code = "stream_error" + + +class StreamClosedError(StreamError): + """Raised when consumers attempt I/O on an already-closed stream.""" + + code = "stream_closed" + + +# --------------------------------------------------------------------------- +# Dispatcher +# --------------------------------------------------------------------------- + + +_STATUS_TO_DEFAULT: Dict[int, type] = { + 400: InvalidRequestError, + 401: AuthenticationError, + 403: PermissionDeniedError, + 404: NotFoundError, + 409: ConflictError, + 429: RateLimitError, + 500: InternalServerError, + 502: InternalServerError, + 503: OverloadedError, + 504: InternalServerError, +} + +_CODE_TO_CLASS: Dict[str, type] = { + "invalid_request_error": InvalidRequestError, + "authentication_error": AuthenticationError, + "permission_denied_error": PermissionDeniedError, + "not_found_error": NotFoundError, + "conflict_error": ConflictError, + "rate_limit_error": RateLimitError, + "overloaded_error": OverloadedError, + "api_error": InternalServerError, +} + + +def from_response( + *, + status_code: int, + body: Any, + headers: Optional[Mapping[str, str]] = None, +) -> AgentStudioError: + """Build an :class:`AgentStudioError` instance from a HTTP response. + + Accepts both the documented ``{type, error:{code,message}, request_id}`` + shape and the pre-release ``{type, error:{error_code, error_message}}`` + shape. Falls back to a Spring default ``{timestamp,status,error,path}`` + when the body is not JSON-serializable. + + The ``x-request-id`` response header is preferred over the body + ``request_id`` field (server-generated IDs are more reliable for tracing). + """ + + code: Optional[str] = None + message: Optional[str] = None + request_id: Optional[str] = None + + # Prefer server-generated request ID from response header. + if headers: + request_id = headers.get("x-request-id") + + if isinstance(body, Mapping): + # Body request_id as fallback (snake_case canonical). + if request_id is None: + request_id = body.get("request_id") or body.get( + "requestId", + ) # TODO(bma-fix) + err = body.get("error") + if isinstance(err, Mapping): + code = err.get("code") or err.get("error_code") # TODO(bma-fix) + message = err.get("message") or err.get( + "error_message", + ) # TODO(bma-fix) + # Spring default fallback. + if ( + message is None + and "error" in body + and isinstance(body["error"], str) + ): + message = body["error"] + code = body.get("error") or "api_error" + if message is None: + message = body.get("message") + + if message is None: + message = f"HTTP {status_code}" + if code is None: + code = _STATUS_TO_DEFAULT.get( # type: ignore[attr-defined] + status_code, + APIStatusError, + ).code + + cls = ( + _CODE_TO_CLASS.get(code) + or _STATUS_TO_DEFAULT.get(status_code) + or APIStatusError + ) + return cls( + message, + code=code, + request_id=request_id, + status_code=status_code, + raw=body if isinstance(body, Mapping) else {"raw": body}, + ) diff --git a/dashscope/agentstudio/pagination.py b/dashscope/agentstudio/pagination.py new file mode 100644 index 0000000..8277128 --- /dev/null +++ b/dashscope/agentstudio/pagination.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Cursor-based pagination. + +AgentStudio uses opaque cursor pagination: callers pass ``limit`` and +``page`` (a cursor token returned by the previous page in +``next_page``). +""" + +from __future__ import annotations + +from typing import ( + Any, + AsyncIterator, + Callable, + Dict, + Generic, + Iterator, + List, + Optional, + TypeVar, +) + +T = TypeVar("T") + + +class CursorPage(Generic[T]): + """Synchronous page over an opaque ``page`` cursor.""" + + def __init__( + self, + *, + data: List[T], + next_page: Optional[str], + request_id: Optional[str], + fetch_next: Optional[Callable[[str], "CursorPage[T]"]] = None, + ) -> None: + self.data = data + self.next_page = next_page + self.request_id = request_id + self._fetch_next = fetch_next + + def has_next(self) -> bool: + return bool(self.next_page) and self._fetch_next is not None + + def get_next(self) -> Optional["CursorPage[T]"]: + if not self.has_next(): + return None + assert self._fetch_next is not None and self.next_page is not None + return self._fetch_next(self.next_page) + + def __iter__(self) -> Iterator[T]: + page: Optional[CursorPage[T]] = self + while page is not None: + for item in page.data: + yield item + page = page.get_next() + + def __len__(self) -> int: + """Return the number of items on *this* page only. + + .. note:: + ``__iter__`` auto-paginates across all pages, so ``len(page)`` + may differ from ``sum(1 for _ in page)``. Use + ``len(page.data)`` for an explicit current-page count. + """ + return len(self.data) + + +class AsyncCursorPage(Generic[T]): + """Asynchronous page over an opaque ``page`` cursor.""" + + def __init__( + self, + *, + data: List[T], + next_page: Optional[str], + request_id: Optional[str], + fetch_next: Optional[Callable[[str], "AsyncCursorPage[T]"]] = None, + ) -> None: + self.data = data + self.next_page = next_page + self.request_id = request_id + self._fetch_next = fetch_next + + def has_next(self) -> bool: + return bool(self.next_page) and self._fetch_next is not None + + async def get_next(self) -> Optional["AsyncCursorPage[T]"]: + if not self.has_next(): + return None + assert self._fetch_next is not None and self.next_page is not None + result = self._fetch_next(self.next_page) + if hasattr(result, "__await__"): + result = await result # type: ignore[assignment] + return result # type: ignore[return-value] + + def __aiter__(self) -> AsyncIterator[T]: + return self._aiter() + + async def _aiter(self) -> AsyncIterator[T]: + page: Optional[AsyncCursorPage[T]] = self + while page is not None: + for item in page.data: + yield item + page = await page.get_next() + + def __len__(self) -> int: + """Return the number of items on *this* page only. + + .. note:: + ``__aiter__`` auto-paginates across all pages, so ``len(page)`` + may differ from the total number of items. Use + ``len(page.data)`` for an explicit current-page count. + """ + return len(self.data) + + +def build_page( + *, + payload: Dict[str, Any], + item_factory: Callable[[Dict[str, Any]], T], + fetch_next: Optional[Callable[[str], Any]] = None, + request_id: Optional[str] = None, + page_cls: type = CursorPage, +) -> Any: + """Hydrate a page object from a normalized payload.""" + + items_raw = payload.get("data") or [] + items: List[T] = [ + item_factory(it) for it in items_raw if isinstance(it, dict) + ] + next_page = payload.get("next_page") + return page_cls( + data=items, + next_page=next_page, + request_id=request_id, + fetch_next=fetch_next, + ) diff --git a/dashscope/agentstudio/resources/__init__.py b/dashscope/agentstudio/resources/__init__.py new file mode 100644 index 0000000..853db58 --- /dev/null +++ b/dashscope/agentstudio/resources/__init__.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Resource classes for the AgentStudio SDK. + +All resource classes are instantiated by the client and accessed as +attributes (e.g. ``client.agents``, ``client.sessions``). + +Usage:: + + from dashscope.agentstudio import Client + + client = Client(api_key="sk-...") + agent = client.agents.create(name="demo", model="qwen-plus") + session = client.sessions.create(agent=agent.id) + client.sessions.events.send(session.id, [user_message("hello")]) +""" + +from dashscope.agentstudio.resources.agents import Agents, AsyncAgents +from dashscope.agentstudio.resources.environments import ( + Environments, + AsyncEnvironments, +) +from dashscope.agentstudio.resources.files import Files, AsyncFiles +from dashscope.agentstudio.resources.session_events import ( + AsyncSessionEvents, + SessionEvents, + _AioTypedEventStream, + _TypedEventStream, +) +from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions +from dashscope.agentstudio.resources.skills import ( + AsyncSkillVersions, + AsyncSkills, + SkillVersions, + Skills, +) +from dashscope.agentstudio.resources.vaults import ( + AsyncCredentials, + AsyncVaults, + Credentials, + Vaults, +) + +__all__ = [ + "Agents", + "AsyncAgents", + "Credentials", + "AsyncCredentials", + "Environments", + "AsyncEnvironments", + "Files", + "AsyncFiles", + "Skills", + "AsyncSkills", + "SkillVersions", + "AsyncSkillVersions", + "Sessions", + "AsyncSessions", + "SessionEvents", + "AsyncSessionEvents", + "Vaults", + "AsyncVaults", + "_TypedEventStream", + "_AioTypedEventStream", +] diff --git a/dashscope/agentstudio/resources/_helpers.py b/dashscope/agentstudio/resources/_helpers.py new file mode 100644 index 0000000..e7ee3c4 --- /dev/null +++ b/dashscope/agentstudio/resources/_helpers.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Shared helper functions for resource modules.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from dashscope.agentstudio.types import ( + Agent, + AgentVersion, + Credential, + Environment, + File, + ServerEvent, + Session, + Skill, + SkillVersion, + Vault, + parse_server_event, +) + + +# --------------------------------------------------------------------------- +# Coerce helpers +# --------------------------------------------------------------------------- + + +def _coerce_agent(payload: Mapping[str, Any]) -> Agent: + return Agent(**dict(payload)) + + +def _coerce_agent_version(payload: Mapping[str, Any]) -> AgentVersion: + return AgentVersion(**dict(payload)) + + +def _coerce_env(payload: Mapping[str, Any]) -> Environment: + return Environment(**dict(payload)) + + +def _coerce_file(payload: Mapping[str, Any]) -> File: + return File(**dict(payload)) + + +def _coerce_skill(payload: Mapping[str, Any]) -> Skill: + return Skill(**dict(payload)) + + +def _coerce_skill_version(payload: Mapping[str, Any]) -> SkillVersion: + return SkillVersion(**dict(payload)) + + +def _coerce_event(payload: Mapping[str, Any]) -> ServerEvent: + return parse_server_event(dict(payload)) + + +def _coerce_session(payload: Mapping[str, Any]) -> Session: + return Session(**dict(payload)) + + +def _coerce_vault(payload: Mapping[str, Any]) -> Vault: + return Vault(**dict(payload)) + + +def _coerce_credential(payload: Mapping[str, Any]) -> Credential: + return Credential(**dict(payload)) + + +# --------------------------------------------------------------------------- +# Path helpers +# --------------------------------------------------------------------------- + + +def _events_path(session_id: str) -> str: + return f"/sessions/{session_id}/events" + + +def _stream_path(session_id: str) -> str: + return f"/sessions/{session_id}/events/stream" diff --git a/dashscope/agentstudio/resources/agents.py b/dashscope/agentstudio/resources/agents.py new file mode 100644 index 0000000..2ded152 --- /dev/null +++ b/dashscope/agentstudio/resources/agents.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Agents resource class.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Sequence + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_agent, + _coerce_agent_version, +) +from dashscope.agentstudio.types import Agent, AgentVersion +from dashscope.agentstudio.types.params import ( + AgentCreateParams, + AgentListParams, + AgentUpdateParams, + AgentVersionListParams, +) + +_PATH_AGENTS = "/agents" + + +class Agents: + """Agent CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + def create( + self, + *, + name: str, + model: str, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Agent: + body = AgentCreateParams( + name=name, + model=model, + description=description, + system_prompt=system_prompt, + tools=tools, + mcp_servers=mcp_servers, + skills=skills, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request("POST", _PATH_AGENTS, json=body) + return _coerce_agent(resp.data) + + def retrieve( + self, + agent_id: str, + *, + version: Optional[int] = None, + ) -> Agent: + params = {"version": version} if version is not None else None + resp = self._client.transport.request( + "GET", + f"{_PATH_AGENTS}/{agent_id}", + params=params, + ) + return _coerce_agent(resp.data) + + # Alias: get() delegates to retrieve(), matching Assistants.get() pattern + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create(), matching Assistants.call() pattern + call = create # type: ignore[assignment] + + def update( + self, + agent_id: str, + *, + version: int, + name: Optional[str] = None, + description: Optional[str] = None, + model: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Agent: + """Update the latest version of an agent. + + ``version`` must equal the server's current version; + the server rejects mismatches with HTTP 409. + Retrieve the agent first to obtain the current version. + """ + body = AgentUpdateParams( + version=version, + name=name, + description=description, + model=model, + system_prompt=system_prompt, + tools=tools, + mcp_servers=mcp_servers, + skills=skills, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_AGENTS}/{agent_id}", + json=body, + ) + return _coerce_agent(resp.data) + + def archive(self, agent_id: str) -> Agent: + resp = self._client.transport.request( + "POST", + f"{_PATH_AGENTS}/{agent_id}/archive", + ) + return _coerce_agent(resp.data) + + def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> CursorPage[Agent]: + params = AgentListParams( + limit=limit, + page=page, + include_archived=include_archived, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_AGENTS, + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_agent, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + limit=limit, + page=nxt, + include_archived=include_archived, + ), + ) + + def list_versions( + self, + agent_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[AgentVersion]: + params = AgentVersionListParams(limit=limit, page=page).to_dict() + resp = self._client.transport.request( + "GET", + f"{_PATH_AGENTS}/{agent_id}/versions", + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_agent_version, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list_versions( + agent_id, + limit=limit, + page=nxt, + ), + ) + + +class AsyncAgents: + """Async Agent CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + async def create( + self, + *, + name: str, + model: str, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Agent: + body = AgentCreateParams( + name=name, + model=model, + description=description, + system_prompt=system_prompt, + tools=tools, + mcp_servers=mcp_servers, + skills=skills, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + _PATH_AGENTS, + json=body, + ) + return _coerce_agent(resp.data) + + async def retrieve( + self, + agent_id: str, + *, + version: Optional[int] = None, + ) -> Agent: + params = {"version": version} if version is not None else None + resp = await self._client.transport.request( + "GET", + f"{_PATH_AGENTS}/{agent_id}", + params=params, + ) + return _coerce_agent(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + async def update( + self, + agent_id: str, + *, + version: int, + name: Optional[str] = None, + description: Optional[str] = None, + model: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Agent: + """Update the latest version of an agent. + + ``version`` must equal the server's current version; + the server rejects mismatches with HTTP 409. + Retrieve the agent first to obtain the current version. + """ + body = AgentUpdateParams( + version=version, + name=name, + description=description, + model=model, + system_prompt=system_prompt, + tools=tools, + mcp_servers=mcp_servers, + skills=skills, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_AGENTS}/{agent_id}", + json=body, + ) + return _coerce_agent(resp.data) + + async def archive(self, agent_id: str) -> Agent: + resp = await self._client.transport.request( + "POST", + f"{_PATH_AGENTS}/{agent_id}/archive", + ) + return _coerce_agent(resp.data) + + async def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> AsyncCursorPage[Agent]: + params = AgentListParams( + limit=limit, + page=page, + include_archived=include_archived, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_AGENTS, + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[Agent]: + return await self.list( + limit=limit, + page=nxt, + include_archived=include_archived, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_agent, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def list_versions( + self, + agent_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[AgentVersion]: + params = AgentVersionListParams(limit=limit, page=page).to_dict() + resp = await self._client.transport.request( + "GET", + f"{_PATH_AGENTS}/{agent_id}/versions", + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[AgentVersion]: + return await self.list_versions( + agent_id, + limit=limit, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_agent_version, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) diff --git a/dashscope/agentstudio/resources/environments.py b/dashscope/agentstudio/resources/environments.py new file mode 100644 index 0000000..8aed2ee --- /dev/null +++ b/dashscope/agentstudio/resources/environments.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Environments resource class.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_env, +) +from dashscope.agentstudio.types import DeleteResponse, Environment +from dashscope.agentstudio.types.params import ( + EnvironmentCreateParams, + EnvironmentListParams, + EnvironmentUpdateParams, +) + + +_PATH_ENVIRONMENTS = "/environments" + + +class Environments: + """Environment CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + def create( + self, + *, + name: str, + config: Mapping[str, Any], + description: Optional[str] = None, + scope: Optional[str] = "organization", + metadata: Optional[Mapping[str, Any]] = None, + extra_headers: Optional[Mapping[str, str]] = None, + ) -> Environment: + body = EnvironmentCreateParams( + name=name, + config=config, + description=description, + scope=scope, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + _PATH_ENVIRONMENTS, + json=body, + extra_headers=extra_headers, + ) + return _coerce_env(resp.data) + + def retrieve(self, environment_id: str) -> Environment: + resp = self._client.transport.request( + "GET", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + ) + return _coerce_env(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + def update( + self, + environment_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + config: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Environment: + body = EnvironmentUpdateParams( + name=name, + description=description, + config=config, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + json=body, + ) + return _coerce_env(resp.data) + + def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> CursorPage[Environment]: + params = EnvironmentListParams( + limit=limit, + page=page, + include_archived=include_archived, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_ENVIRONMENTS, + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_env, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + limit=limit, + page=nxt, + include_archived=include_archived, + ), + ) + + def archive(self, environment_id: str) -> Environment: + resp = self._client.transport.request( + "POST", + f"{_PATH_ENVIRONMENTS}/{environment_id}/archive", + ) + return _coerce_env(resp.data) + + def delete(self, environment_id: str) -> DeleteResponse: + resp = self._client.transport.request( + "DELETE", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + ) + return DeleteResponse(**resp.data) + + +class AsyncEnvironments: + """Async Environment CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + async def create( + self, + *, + name: str, + config: Mapping[str, Any], + description: Optional[str] = None, + scope: Optional[str] = "organization", + metadata: Optional[Mapping[str, Any]] = None, + extra_headers: Optional[Mapping[str, str]] = None, + ) -> Environment: + body = EnvironmentCreateParams( + name=name, + config=config, + description=description, + scope=scope, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + _PATH_ENVIRONMENTS, + json=body, + extra_headers=extra_headers, + ) + return _coerce_env(resp.data) + + async def retrieve(self, environment_id: str) -> Environment: + resp = await self._client.transport.request( + "GET", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + ) + return _coerce_env(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + async def update( + self, + environment_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + config: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Environment: + body = EnvironmentUpdateParams( + name=name, + description=description, + config=config, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + json=body, + ) + return _coerce_env(resp.data) + + async def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> AsyncCursorPage[Environment]: + params = EnvironmentListParams( + limit=limit, + page=page, + include_archived=include_archived, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_ENVIRONMENTS, + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[Environment]: + return await self.list( + limit=limit, + page=nxt, + include_archived=include_archived, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_env, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def archive(self, environment_id: str) -> Environment: + resp = await self._client.transport.request( + "POST", + f"{_PATH_ENVIRONMENTS}/{environment_id}/archive", + ) + return _coerce_env(resp.data) + + async def delete(self, environment_id: str) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + ) + return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/files.py b/dashscope/agentstudio/resources/files.py new file mode 100644 index 0000000..348c3b5 --- /dev/null +++ b/dashscope/agentstudio/resources/files.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Files resource class.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import ( + Any, + BinaryIO, + Callable, + Dict, + IO, + Mapping, + Optional, + Tuple, + Union, +) + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_file, +) +from dashscope.agentstudio.types import DeleteResponse, File +from dashscope.agentstudio.types.params import FileListParams + + +_PATH_FILES = "/files" + +# (bytes_read, total_bytes_or_-1) -- called after every chunk. +ProgressCallback = Callable[[int, int], None] + + +def _open_file( + file: Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]], +) -> Tuple[str, BinaryIO, bool]: + """Return ``(filename, fileobj, must_close)``.""" + if isinstance(file, tuple) and len(file) == 2: + name, obj = file + return str(name), obj, False + if isinstance(file, (str, bytes, os.PathLike)): + path = Path(os.fspath(file)) + return path.name, path.open("rb"), True + name = getattr(file, "name", "upload.bin") + return os.path.basename(str(name)), file, False + + +class _ProgressFile: + """Wrap a file-like object so callers can observe upload progress.""" + + def __init__( + self, + fileobj: IO[bytes], + total: int, + callback: ProgressCallback, + ) -> None: + self._fileobj = fileobj + self._total = total + self._read = 0 + self._callback = callback + + def __len__(self) -> int: + return self._total + + def read(self, size: int = -1) -> bytes: + chunk = self._fileobj.read(size) + if chunk: + self._read += len(chunk) + try: + self._callback(self._read, self._total) + except Exception: + pass + return chunk + + def seek(self, offset: int, whence: int = 0) -> int: + result = self._fileobj.seek(offset, whence) + if whence == 0: + self._read = offset + elif whence == 1: + self._read += offset + elif whence == 2: + self._read = self._total + offset + return result + + def tell(self) -> int: + return self._fileobj.tell() + + @property + def name(self) -> str: + return getattr(self._fileobj, "name", "upload.bin") + + +def _file_size(fileobj: IO[bytes]) -> int: + try: + cur = fileobj.tell() + except (AttributeError, OSError): + return -1 + try: + fileobj.seek(0, os.SEEK_END) + end = fileobj.tell() + fileobj.seek(cur) + return end - cur + except (AttributeError, OSError): + return -1 + + +class Files: + """File upload / list / delete.""" + + def __init__(self, client) -> None: + self._client = client + + def upload( + self, + file: Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]], + *, + mime_type: Optional[str] = None, + progress: Optional[ProgressCallback] = None, + extra_headers: Optional[Mapping[str, str]] = None, + ) -> File: + filename, fileobj, must_close = _open_file(file) + try: + payload_obj: Any + if progress is not None: + size = _file_size(fileobj) + if size > 0: + payload_obj = _ProgressFile(fileobj, size, progress) + else: + payload_obj = fileobj + else: + payload_obj = fileobj + multipart: Dict[str, Any] = { + "file": ( + filename, + payload_obj, + mime_type or "application/octet-stream", + ), + } + resp = self._client.transport.request( + "POST", + _PATH_FILES, + files=multipart, + extra_headers=extra_headers, + ) + return _coerce_file(resp.data) + finally: + if must_close: + try: + fileobj.close() + except Exception: + pass + + def retrieve(self, file_id: str) -> File: + resp = self._client.transport.request( + "GET", + f"{_PATH_FILES}/{file_id}", + ) + return _coerce_file(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + scope_id: Optional[str] = None, + ) -> CursorPage[File]: + params = FileListParams( + limit=limit, + page=page, + scope_id=scope_id, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_FILES, + params=params, + ) + + def fetch_next(token: str) -> CursorPage[File]: + return self.list( + limit=limit, + page=token, + scope_id=scope_id, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_file, + request_id=resp.request_id, + page_cls=CursorPage, + fetch_next=fetch_next, + ) + + def delete(self, file_id: str) -> DeleteResponse: + resp = self._client.transport.request( + "DELETE", + f"{_PATH_FILES}/{file_id}", + ) + return DeleteResponse(**resp.data) + + +class AsyncFiles: + """Async file upload / list / delete.""" + + def __init__(self, client) -> None: + self._client = client + + async def upload( + self, + file: Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]], + *, + mime_type: Optional[str] = None, + progress: Optional[ProgressCallback] = None, + extra_headers: Optional[Mapping[str, str]] = None, + ) -> File: + filename, fileobj, must_close = _open_file(file) + try: + payload_obj: Any + if progress is not None: + size = _file_size(fileobj) + if size > 0: + payload_obj = _ProgressFile(fileobj, size, progress) + else: + payload_obj = fileobj + else: + payload_obj = fileobj + multipart: Dict[str, Any] = { + "file": ( + filename, + payload_obj, + mime_type or "application/octet-stream", + ), + } + resp = await self._client.transport.request( + "POST", + _PATH_FILES, + files=multipart, + extra_headers=extra_headers, + ) + return _coerce_file(resp.data) + finally: + if must_close: + try: + fileobj.close() + except Exception: + pass + + async def retrieve(self, file_id: str) -> File: + resp = await self._client.transport.request( + "GET", + f"{_PATH_FILES}/{file_id}", + ) + return _coerce_file(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + async def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + scope_id: Optional[str] = None, + ) -> AsyncCursorPage[File]: + params = FileListParams( + limit=limit, + page=page, + scope_id=scope_id, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_FILES, + params=params, + ) + + async def fetch_next(token: str) -> AsyncCursorPage[File]: + return await self.list( + limit=limit, + page=token, + scope_id=scope_id, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_file, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def delete(self, file_id: str) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_FILES}/{file_id}", + ) + return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/session_events.py b/dashscope/agentstudio/resources/session_events.py new file mode 100644 index 0000000..7007de9 --- /dev/null +++ b/dashscope/agentstudio/resources/session_events.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""SessionEvents and AsyncSessionEvents resource classes.""" + +from __future__ import annotations + +from typing import ( + Any, + AsyncIterator, + Dict, + Iterator, + Mapping, + Optional, + Sequence, +) + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_event, + _events_path, + _stream_path, +) +from dashscope.agentstudio.streaming import AsyncEventStream, EventStream +from dashscope.agentstudio.types import ServerEvent +from dashscope.agentstudio.types.params import ( + SessionEventListParams, + SessionEventSendParams, +) +from dashscope.agentstudio.constants import ( + AGENTSTUDIO_DEFAULT_TIMEOUT, + BlockType, + SessionStatus, + SSEEventType, +) + + +class SessionEvents: + """Session event send / list / stream.""" + + def __init__(self, client) -> None: + self._client = client + + def send( + self, + session_id: str, + events: Sequence[Mapping[str, Any]], + ) -> Dict[str, Any]: + """Send events to a session. + + Returns the server response dict:: + + result = client.sessions.events.send( + session.id, [user_message("hello")], + ) + """ + if not events: + raise ValueError("events must contain at least 1 entry") + body = SessionEventSendParams(input=events).to_dict() + resp = self._client.transport.request( + "POST", + _events_path(session_id), + json=body, + ) + return resp.data + + def list( + self, + session_id: str, + *, + types: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + limit: Optional[int] = None, + order: Optional[str] = None, + page: Optional[str] = None, + ) -> CursorPage[ServerEvent]: + params = SessionEventListParams( + types=types, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + limit=limit, + order=order, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + _events_path(session_id), + params=params, + ) + + def fetch_next(nxt: str) -> CursorPage[ServerEvent]: + return self.list( + session_id, + types=types, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + limit=limit, + order=order, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_event, + request_id=resp.request_id, + fetch_next=fetch_next, + ) + + def stream( + self, + session_id: str, + *, + timeout: Optional[float] = None, + ) -> "_TypedEventStream": + """Open the SSE stream and return an iterator of typed events.""" + + resp = self._client.transport.request( + "GET", + _stream_path(session_id), + extra_headers={"Accept": "text/event-stream"}, + stream=True, + timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + ) + return _TypedEventStream( + EventStream(response=resp), + ) + + +class _TypedEventStream: + """Wrap :class:`EventStream` so iterating yields :class:`ServerEvent`.""" + + def __init__(self, stream: EventStream) -> None: + self._stream = stream + + def __enter__(self) -> "_TypedEventStream": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def __iter__(self) -> Iterator[ServerEvent]: + for payload in self._stream: + yield _coerce_event(payload) + + @property + def text_stream(self): + """Iterate over text chunks from agent messages. + + Automatically stops when the session reaches ``idle`` or + ``terminated`` status, so callers don't need to handle + ``session_status`` events manually. + """ + for event in self: + if getattr(event, "type", None) == SSEEventType.MESSAGE: + for block in event.content or []: + if getattr(block, "type", None) == BlockType.TEXT: + text = getattr(block, "text", "") + if text: + yield text + elif getattr(event, "type", None) == SSEEventType.SESSION_STATUS: + block = event.content[0] if event.content else None + d = getattr(block, "data", None) or {} + if d.get("session_status") in ( + SessionStatus.IDLE, + SessionStatus.TERMINATED, + SessionStatus.RESCHEDULING, + ): + return + + def close(self) -> None: + self._stream.close() + + +class AsyncSessionEvents: + """Async session event operations.""" + + def __init__(self, client) -> None: + self._client = client + + async def send( + self, + session_id: str, + events: Sequence[Mapping[str, Any]], + ) -> Dict[str, Any]: + """Send events to a session. + + Returns the server response dict:: + + result = await client.sessions.events.send( + session.id, [user_message("hello")], + ) + """ + if not events: + raise ValueError("events must contain at least 1 entry") + body = SessionEventSendParams(input=events).to_dict() + resp = await self._client.transport.request( + "POST", + _events_path(session_id), + json=body, + ) + return resp.data + + async def list( + self, + session_id: str, + *, + types: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + limit: Optional[int] = None, + order: Optional[str] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[ServerEvent]: + params = SessionEventListParams( + types=types, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + limit=limit, + order=order, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _events_path(session_id), + params=params, + ) + + async def fetch_next( + nxt: str, + ) -> AsyncCursorPage[ServerEvent]: + return await self.list( + session_id, + types=types, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + limit=limit, + order=order, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_event, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def stream( + self, + session_id: str, + *, + timeout: Optional[float] = None, + ) -> "_AioTypedEventStream": + """Open the SSE stream and return an async iterator of typed events.""" + + resp = await self._client.transport.request( + "GET", + _stream_path(session_id), + extra_headers={"Accept": "text/event-stream"}, + stream=True, + timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + ) + return _AioTypedEventStream( + AsyncEventStream(response=resp), + ) + + +class _AioTypedEventStream: + def __init__(self, stream: AsyncEventStream) -> None: + self._stream = stream + + async def __aenter__(self) -> "_AioTypedEventStream": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.aclose() + + def __aiter__(self) -> AsyncIterator[ServerEvent]: + return self._aiter() + + async def _aiter(self) -> AsyncIterator[ServerEvent]: + async for payload in self._stream: + yield _coerce_event(payload) + + @property + def text_stream(self): + """Async iterator over text chunks from agent messages.""" + return self._text_stream() + + async def _text_stream(self): + """Async iterator over text chunks from agent messages. + + Automatically stops when the session reaches ``idle`` or + ``terminated`` status, so callers don't need to handle + ``session_status`` events manually. + """ + async for event in self: + if getattr(event, "type", None) == SSEEventType.MESSAGE: + for block in event.content or []: + if getattr(block, "type", None) == BlockType.TEXT: + text = getattr(block, "text", "") + if text: + yield text + elif getattr(event, "type", None) == SSEEventType.SESSION_STATUS: + block = event.content[0] if event.content else None + d = getattr(block, "data", None) or {} + if d.get("session_status") in ( + SessionStatus.IDLE, + SessionStatus.TERMINATED, + SessionStatus.RESCHEDULING, + ): + return + + async def aclose(self) -> None: + await self._stream.aclose() diff --git a/dashscope/agentstudio/resources/sessions.py b/dashscope/agentstudio/resources/sessions.py new file mode 100644 index 0000000..3b299c6 --- /dev/null +++ b/dashscope/agentstudio/resources/sessions.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Sessions resource class.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Sequence + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_session, +) +from dashscope.agentstudio.resources.session_events import ( + SessionEvents, + AsyncSessionEvents, +) +from dashscope.agentstudio.types import DeleteResponse, Session +from dashscope.agentstudio.types.params import ( + SessionCreateParams, + SessionListParams, + SessionUpdateParams, +) + + +_PATH_SESSIONS = "/sessions" + + +class Sessions: + """Session CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + self.events = SessionEvents(client) + + def create( + self, + *, + agent: str, + environment_id: Optional[str] = None, + title: Optional[str] = None, + resources: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Session: + body = SessionCreateParams( + agent=agent, + environment_id=environment_id, + title=title, + resources=resources, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + _PATH_SESSIONS, + json=body, + ) + return _coerce_session(resp.data) + + def retrieve(self, session_id: str) -> Session: + resp = self._client.transport.request( + "GET", + f"{_PATH_SESSIONS}/{session_id}", + ) + return _coerce_session(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + def update( + self, + session_id: str, + *, + title: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Session: + body = SessionUpdateParams( + title=title, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_SESSIONS}/{session_id}", + json=body, + ) + return _coerce_session(resp.data) + + def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + agent_id: Optional[str] = None, + statuses: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + ) -> CursorPage[Session]: + params = SessionListParams( + limit=limit, + page=page, + agent_id=agent_id, + statuses=statuses, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_SESSIONS, + params=params, + ) + + def fetch_next(nxt: str) -> CursorPage[Session]: + return self.list( + limit=limit, + page=nxt, + agent_id=agent_id, + statuses=statuses, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_session, + request_id=resp.request_id, + fetch_next=fetch_next, + ) + + def archive(self, session_id: str) -> Session: + resp = self._client.transport.request( + "POST", + f"{_PATH_SESSIONS}/{session_id}/archive", + ) + return _coerce_session(resp.data) + + def delete(self, session_id: str) -> DeleteResponse: + resp = self._client.transport.request( + "DELETE", + f"{_PATH_SESSIONS}/{session_id}", + ) + return DeleteResponse(**resp.data) + + +class AsyncSessions: + """Async Session CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + self.events = AsyncSessionEvents(client) + + async def create( + self, + *, + agent: str, + environment_id: Optional[str] = None, + title: Optional[str] = None, + resources: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Session: + body = SessionCreateParams( + agent=agent, + environment_id=environment_id, + title=title, + resources=resources, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + _PATH_SESSIONS, + json=body, + ) + return _coerce_session(resp.data) + + async def retrieve(self, session_id: str) -> Session: + resp = await self._client.transport.request( + "GET", + f"{_PATH_SESSIONS}/{session_id}", + ) + return _coerce_session(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + async def update( + self, + session_id: str, + *, + title: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Session: + body = SessionUpdateParams( + title=title, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_SESSIONS}/{session_id}", + json=body, + ) + return _coerce_session(resp.data) + + async def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + agent_id: Optional[str] = None, + statuses: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + ) -> AsyncCursorPage[Session]: + params = SessionListParams( + limit=limit, + page=page, + agent_id=agent_id, + statuses=statuses, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_SESSIONS, + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[Session]: + return await self.list( + limit=limit, + page=nxt, + agent_id=agent_id, + statuses=statuses, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_session, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def archive(self, session_id: str) -> Session: + resp = await self._client.transport.request( + "POST", + f"{_PATH_SESSIONS}/{session_id}/archive", + ) + return _coerce_session(resp.data) + + async def delete(self, session_id: str) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_SESSIONS}/{session_id}", + ) + return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/skills.py b/dashscope/agentstudio/resources/skills.py new file mode 100644 index 0000000..c9d9833 --- /dev/null +++ b/dashscope/agentstudio/resources/skills.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Skills and SkillVersions resource classes.""" + +from __future__ import annotations + +import os # pylint: disable=unused-import # noqa: F401 +from typing import Any, BinaryIO, Dict, Optional, Tuple, Union + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_skill, + _coerce_skill_version, +) +from dashscope.agentstudio.types import DeleteResponse, Skill, SkillVersion +from dashscope.agentstudio.types.params import ( + SkillCreateParams, + SkillListParams, + SkillVersionCreateParams, + SkillVersionListParams, +) + + +_PATH_SKILLS = "/skills" + +FileSource = Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]] + + +class Skills: + """Skill CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + self.versions = SkillVersions(client) + + def create( + self, + *, + file_id: Optional[str] = None, + file: Optional[FileSource] = None, + mime_type: Optional[str] = None, + ) -> Skill: + resolved = _resolve_file_id_sync( + client=self._client, + file_id=file_id, + file=file, + mime_type=mime_type, + ) + body = SkillCreateParams(file_id=resolved).to_dict() + resp = self._client.transport.request("POST", _PATH_SKILLS, json=body) + return _coerce_skill(resp.data) + + def retrieve(self, skill_id: str) -> Skill: + resp = self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}", + ) + return _coerce_skill(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + def list( + self, + *, + source: Optional[str] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[Skill]: + params = SkillListParams( + source=source, + limit=limit, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_SKILLS, + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_skill, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + source=source, + limit=limit, + page=nxt, + ), + ) + + def delete(self, skill_id: str) -> DeleteResponse: + resp = self._client.transport.request( + "DELETE", + f"{_PATH_SKILLS}/{skill_id}", + ) + return DeleteResponse(**resp.data) + + +class SkillVersions: + """Skill version sub-resource.""" + + def __init__(self, client) -> None: + self._client = client + + def create( + self, + skill_id: str, + *, + file_id: Optional[str] = None, + file: Optional[FileSource] = None, + mime_type: Optional[str] = None, + ) -> SkillVersion: + resolved = _resolve_file_id_sync( + client=self._client, + file_id=file_id, + file=file, + mime_type=mime_type, + ) + body = SkillVersionCreateParams(file_id=resolved).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_SKILLS}/{skill_id}/versions", + json=body, + ) + return _coerce_skill_version(resp.data) + + def list( + self, + skill_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[SkillVersion]: + params = SkillVersionListParams(limit=limit, page=page).to_dict() + resp = self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions", + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_skill_version, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + skill_id, + limit=limit, + page=nxt, + ), + ) + + def retrieve(self, skill_id: str, version: str) -> SkillVersion: + resp = self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions/{version}", + ) + return _coerce_skill_version(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + def download(self, skill_id: str, version: str) -> Dict[str, Any]: + resp = self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions/{version}/content", + ) + return dict(resp.data) + + +class AsyncSkills: + """Async Skill CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + self.versions = AsyncSkillVersions(client) + + async def create( + self, + *, + file_id: Optional[str] = None, + file: Optional[FileSource] = None, + mime_type: Optional[str] = None, + ) -> Skill: + resolved = await _resolve_file_id_async( + client=self._client, + file_id=file_id, + file=file, + mime_type=mime_type, + ) + body = SkillCreateParams(file_id=resolved).to_dict() + resp = await self._client.transport.request( + "POST", + _PATH_SKILLS, + json=body, + ) + return _coerce_skill(resp.data) + + async def retrieve(self, skill_id: str) -> Skill: + resp = await self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}", + ) + return _coerce_skill(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + async def list( + self, + *, + source: Optional[str] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[Skill]: + params = SkillListParams( + source=source, + limit=limit, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_SKILLS, + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[Skill]: + return await self.list( + source=source, + limit=limit, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_skill, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def delete(self, skill_id: str) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_SKILLS}/{skill_id}", + ) + return DeleteResponse(**resp.data) + + +class AsyncSkillVersions: + """Async skill version sub-resource.""" + + def __init__(self, client) -> None: + self._client = client + + async def create( + self, + skill_id: str, + *, + file_id: Optional[str] = None, + file: Optional[FileSource] = None, + mime_type: Optional[str] = None, + ) -> SkillVersion: + resolved = await _resolve_file_id_async( + client=self._client, + file_id=file_id, + file=file, + mime_type=mime_type, + ) + body = SkillVersionCreateParams(file_id=resolved).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_SKILLS}/{skill_id}/versions", + json=body, + ) + return _coerce_skill_version(resp.data) + + async def list( + self, + skill_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[SkillVersion]: + params = SkillVersionListParams(limit=limit, page=page).to_dict() + resp = await self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions", + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[SkillVersion]: + return await self.list( + skill_id, + limit=limit, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_skill_version, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def retrieve(self, skill_id: str, version: str) -> SkillVersion: + resp = await self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions/{version}", + ) + return _coerce_skill_version(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + async def download(self, skill_id: str, version: str) -> Dict[str, Any]: + resp = await self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions/{version}/content", + ) + return dict(resp.data) + + +# --------------------------------------------------------------------------- +# File-ID resolution helpers (internal) +# --------------------------------------------------------------------------- + + +def _resolve_file_id_sync( + *, + client: Any, + file_id: Optional[str], + file: Optional[FileSource], + mime_type: Optional[str], +) -> str: + if (file_id is None) == (file is None): + raise TypeError( + "skills upload requires exactly one of file_id=... or file=...", + ) + if file_id is not None: + return file_id + # Use the client's files resource to upload + uploaded = client.files.upload( + file, + mime_type=mime_type or "application/zip", + ) + return uploaded.id + + +async def _resolve_file_id_async( + *, + client: Any, + file_id: Optional[str], + file: Optional[FileSource], + mime_type: Optional[str], +) -> str: + if (file_id is None) == (file is None): + raise TypeError( + "skills upload requires exactly one of file_id=... or file=...", + ) + if file_id is not None: + return file_id + uploaded = await client.files.upload( + file, + mime_type=mime_type or "application/zip", + ) + return uploaded.id diff --git a/dashscope/agentstudio/resources/vaults.py b/dashscope/agentstudio/resources/vaults.py new file mode 100644 index 0000000..c68e492 --- /dev/null +++ b/dashscope/agentstudio/resources/vaults.py @@ -0,0 +1,476 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Vaults and Credentials resource classes.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_credential, + _coerce_vault, +) +from dashscope.agentstudio.types import ( + Credential, + DeleteResponse, + Vault, +) +from dashscope.agentstudio.types.params import ( + CredentialCreateParams, + CredentialListParams, + CredentialUpdateParams, + VaultCreateParams, + VaultListParams, + VaultUpdateParams, +) + + +_PATH_VAULTS = "/vaults" + + +# --------------------------------------------------------------------------- +# Credentials (sub-resource of Vaults) +# --------------------------------------------------------------------------- + + +class Credentials: + def __init__(self, client) -> None: + self._client = client + + def create( + self, + vault_id: str, + *, + auth: Any, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Credential: + body = CredentialCreateParams( + auth=auth, + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials", + json=body, + ) + return _coerce_credential(resp.data) + + def retrieve( + self, + vault_id: str, + credential_id: str, + ) -> Credential: + resp = self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + ) + return _coerce_credential(resp.data) + + get = retrieve # type: ignore[assignment] + + def update( + self, + vault_id: str, + credential_id: str, + *, + auth: Any = None, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Credential: + body = CredentialUpdateParams( + auth=auth, + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + json=body, + ) + return _coerce_credential(resp.data) + + def list( + self, + vault_id: str, + *, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[Credential]: + params = CredentialListParams( + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}/credentials", + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_credential, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + vault_id, + include_archived=include_archived, + limit=limit, + page=nxt, + ), + ) + + def delete( + self, + vault_id: str, + credential_id: str, + ) -> DeleteResponse: + resp = self._client.transport.request( + "DELETE", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + ) + return DeleteResponse(**resp.data) + + def archive( + self, + vault_id: str, + credential_id: str, + ) -> Credential: + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials" + f"/{credential_id}/archive", + ) + return _coerce_credential(resp.data) + + +# --------------------------------------------------------------------------- +# Vaults (parent resource) +# --------------------------------------------------------------------------- + + +class Vaults: + def __init__(self, client) -> None: + self._client = client + self.credentials = Credentials(client) + + def create( + self, + *, + display_name: str, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Vault: + body = VaultCreateParams( + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + _PATH_VAULTS, + json=body, + ) + return _coerce_vault(resp.data) + + def retrieve(self, vault_id: str) -> Vault: + resp = self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}", + ) + return _coerce_vault(resp.data) + + get = retrieve # type: ignore[assignment] + + def update( + self, + vault_id: str, + *, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Vault: + body = VaultUpdateParams( + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}", + json=body, + ) + return _coerce_vault(resp.data) + + def list( + self, + *, + keyword: Optional[str] = None, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[Vault]: + params = VaultListParams( + keyword=keyword, + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_VAULTS, + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_vault, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + keyword=keyword, + include_archived=include_archived, + limit=limit, + page=nxt, + ), + ) + + def delete(self, vault_id: str) -> DeleteResponse: + resp = self._client.transport.request( + "DELETE", + f"{_PATH_VAULTS}/{vault_id}", + ) + return DeleteResponse(**resp.data) + + def archive(self, vault_id: str) -> Vault: + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/archive", + ) + return _coerce_vault(resp.data) + + +# --------------------------------------------------------------------------- +# Async variants +# --------------------------------------------------------------------------- + + +class AsyncCredentials: + def __init__(self, client) -> None: + self._client = client + + async def create( + self, + vault_id: str, + *, + auth: Any, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Credential: + body = CredentialCreateParams( + auth=auth, + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials", + json=body, + ) + return _coerce_credential(resp.data) + + async def retrieve( + self, + vault_id: str, + credential_id: str, + ) -> Credential: + resp = await self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + ) + return _coerce_credential(resp.data) + + get = retrieve # type: ignore[assignment] + + async def update( + self, + vault_id: str, + credential_id: str, + *, + auth: Any = None, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Credential: + body = CredentialUpdateParams( + auth=auth, + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + json=body, + ) + return _coerce_credential(resp.data) + + async def list( + self, + vault_id: str, + *, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[Credential]: + params = CredentialListParams( + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}/credentials", + params=params, + ) + + async def fetch_next( + nxt: str, + ) -> AsyncCursorPage[Credential]: + return await self.list( + vault_id, + include_archived=include_archived, + limit=limit, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_credential, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def delete( + self, + vault_id: str, + credential_id: str, + ) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + ) + return DeleteResponse(**resp.data) + + async def archive( + self, + vault_id: str, + credential_id: str, + ) -> Credential: + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials" + f"/{credential_id}/archive", + ) + return _coerce_credential(resp.data) + + +class AsyncVaults: + def __init__(self, client) -> None: + self._client = client + self.credentials = AsyncCredentials(client) + + async def create( + self, + *, + display_name: str, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Vault: + body = VaultCreateParams( + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + _PATH_VAULTS, + json=body, + ) + return _coerce_vault(resp.data) + + async def retrieve(self, vault_id: str) -> Vault: + resp = await self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}", + ) + return _coerce_vault(resp.data) + + get = retrieve # type: ignore[assignment] + + async def update( + self, + vault_id: str, + *, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Vault: + body = VaultUpdateParams( + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}", + json=body, + ) + return _coerce_vault(resp.data) + + async def list( + self, + *, + keyword: Optional[str] = None, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[Vault]: + params = VaultListParams( + keyword=keyword, + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_VAULTS, + params=params, + ) + + async def fetch_next( + nxt: str, + ) -> AsyncCursorPage[Vault]: + return await self.list( + keyword=keyword, + include_archived=include_archived, + limit=limit, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_vault, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def delete(self, vault_id: str) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_VAULTS}/{vault_id}", + ) + return DeleteResponse(**resp.data) + + async def archive(self, vault_id: str) -> Vault: + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/archive", + ) + return _coerce_vault(resp.data) diff --git a/dashscope/agentstudio/streaming.py b/dashscope/agentstudio/streaming.py new file mode 100644 index 0000000..bdbdc68 --- /dev/null +++ b/dashscope/agentstudio/streaming.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""SSE (Server-Sent Events) stream iterators backed by ``httpx-sse``. + +The AgentStudio stream is documented as:: + + event: message + data: + + : keepalive + +The ``event:`` field is always the literal ``"message"`` and never used +for routing – the actual event type lives in ``data.type``. The server +does not emit ``id:`` lines; reconnection is performed via +``GET /sessions/{id}/events`` with ``created_at[gt]=...`` plus +client-side dedup by ``data.id``. + +This module wraps ``httpx_sse.EventSource`` in iterator classes that +yield parsed JSON dicts for consumption by the typed event stream layer. + +A wall-clock ``idle_timeout`` (default 120 s) guards against the server +silently dropping business events while still sending ``: keepalive`` +frames — ``httpx``'s read timeout gets reset by every keepalive, so +without an application-level watchdog the iterator would block forever. +When no business event arrives for ``idle_timeout`` seconds, the +underlying response is forcibly closed so ``iter_sse()`` unblocks. +""" + +from __future__ import annotations + +import asyncio +import json +import queue +import threading +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Dict, Iterator, Optional + +import httpx +from httpx_sse import EventSource + +from dashscope.agentstudio import exceptions +from dashscope.common.logging import logger + +_DEFAULT_IDLE_TIMEOUT = 120.0 + + +# --------------------------------------------------------------------------- +# Iterators +# --------------------------------------------------------------------------- + + +@dataclass +class EventStream: + """Synchronous iterator over an SSE response backed by ``httpx-sse``. + + Emits dict payloads (the parsed ``data`` JSON of each frame). Comment + / keepalive frames are silently skipped. The underlying response is + closed when iteration completes or :meth:`close` is invoked. + + A wall-clock ``idle_timeout`` guards against the server silently + dropping business events while still sending ``: keepalive`` frames. + ``httpx``'s read timeout gets reset by every keepalive, and + ``response.close()`` does not unblock a blocking ``iter_sse()`` + call, so the iterator is drained on a background thread while the + foreground loop pulls events through a queue with timeout. + """ + + response: httpx.Response + idle_timeout: Optional[float] = _DEFAULT_IDLE_TIMEOUT + _event_source: EventSource = field(init=False) + _closed: bool = False + _producer: Optional[threading.Thread] = field(init=False, default=None) + + def __post_init__(self) -> None: + self._event_source = EventSource(self.response) + + def __enter__(self) -> "EventStream": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + def __iter__(self) -> Iterator[Dict[str, Any]]: + return self._iter() + + def _iter(self) -> Iterator[Dict[str, Any]]: + if self._closed: + raise exceptions.StreamClosedError("stream already closed") + q: "queue.Queue[Any]" = queue.Queue() + + def _produce() -> None: + try: + for sse in self._event_source.iter_sse(): + if sse.data: + try: + payload = json.loads(sse.data) + except json.JSONDecodeError: + logger.warning("SSE frame contains invalid JSON") + q.put({"_raw": sse.data}) + continue + if payload: + q.put(payload) + except Exception as exc: # noqa: W0718 + q.put(exc) + finally: + q.put(None) + + self._producer = threading.Thread(target=_produce, daemon=True) + self._producer.start() + + try: + while True: + try: + item = q.get(timeout=self.idle_timeout) + except queue.Empty: + logger.warning( + "SSE stream idle for %ss, closing response", + self.idle_timeout, + ) + self.close() + return + if item is None: + return + if isinstance(item, Exception): + raise item + yield item + finally: + self.close() + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + self.response.close() + except Exception: # pragma: no cover + pass + + +@dataclass +class AsyncEventStream: + """Async iterator over an SSE response backed by ``httpx-sse``. + + See :class:`EventStream` for the ``idle_timeout`` rationale — the + async variant drains ``aiter_sse()`` on a background task and pulls + events through an :class:`asyncio.Queue` with ``wait_for`` timeout. + """ + + response: httpx.Response + idle_timeout: Optional[float] = _DEFAULT_IDLE_TIMEOUT + _event_source: EventSource = field(init=False) + _closed: bool = False + _producer: Optional[asyncio.Task] = field(init=False, default=None) + + def __post_init__(self) -> None: + self._event_source = EventSource(self.response) + + async def __aenter__(self) -> "AsyncEventStream": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + def __aiter__(self) -> AsyncIterator[Dict[str, Any]]: + return self._aiter() + + async def _aiter(self) -> AsyncIterator[Dict[str, Any]]: + if self._closed: + raise exceptions.StreamClosedError("stream already closed") + q: "asyncio.Queue[Any]" = asyncio.Queue() + + async def _produce() -> None: + try: + async for sse in self._event_source.aiter_sse(): + if sse.data: + try: + payload = json.loads(sse.data) + except json.JSONDecodeError: + logger.warning("SSE frame contains invalid JSON") + await q.put({"_raw": sse.data}) + continue + if payload: + await q.put(payload) + except Exception as exc: # noqa: W0718 + await q.put(exc) + finally: + await q.put(None) + + self._producer = asyncio.create_task(_produce()) + + try: + while True: + try: + item = await asyncio.wait_for( + q.get(), + timeout=self.idle_timeout, + ) + except asyncio.TimeoutError: + logger.warning( + "SSE stream idle for %ss, closing response", + self.idle_timeout, + ) + await self.aclose() + return + if item is None: + return + if isinstance(item, Exception): + raise item + yield item + finally: + await self.aclose() + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + if self._producer is not None: + self._producer.cancel() + try: + await self._producer + except (asyncio.CancelledError, Exception): + pass + try: + await self.response.aclose() + except Exception: # pragma: no cover + pass diff --git a/dashscope/agentstudio/transport.py b/dashscope/agentstudio/transport.py new file mode 100644 index 0000000..fe85edd --- /dev/null +++ b/dashscope/agentstudio/transport.py @@ -0,0 +1,696 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""HTTP transport for the AgentStudio SDK. + +This module wraps ``httpx.Client`` (sync) and ``httpx.AsyncClient`` +(async) so the rest of the SDK can speak in plain Python data structures. +The transport layer is responsible for: + +* injecting auth / workspace / uid headers, +* JSON encoding / decoding the request and response bodies, +* converting non-2xx responses into :class:`AgentStudioError`, +* retry-on-network-error with bounded exponential backoff. + +It deliberately does *not* implement model marshalling – that lives in +the resource modules so each endpoint can hydrate its own typed shapes. +""" + +from __future__ import annotations + +import asyncio +import logging +import random +import socket +import time +from dataclasses import dataclass +from typing import Any, Dict, IO, Mapping, Optional, Tuple, Union + +import httpx + +from dashscope.agentstudio import exceptions +from dashscope.agentstudio.constants import ( + AGENTSTUDIO_DEFAULT_TIMEOUT, + AGENTSTUDIO_MAX_RETRIES, +) + +logger = logging.getLogger("dashscope.agentstudio") + +# --------------------------------------------------------------------------- +# Base data classes (merged from _base.py) +# --------------------------------------------------------------------------- + + +@dataclass +class RequestOptions: + """Typed HTTP request options. + + Encapsulates all parameters needed to issue an HTTP request through + the transport layer, replacing bare keyword arguments for better + type safety and testability. + """ + + method: str + path: str + json: Optional[Dict[str, Any]] = None + params: Optional[Dict[str, Any]] = None + data: Optional[Union[bytes, IO]] = None + files: Optional[Dict[str, Any]] = None + headers: Optional[Dict[str, str]] = None + timeout: Optional[float] = None + max_retries: Optional[int] = None + stream: bool = False + + +@dataclass +class APIResponse: + """Typed API response. + + Wraps the parsed response data together with metadata (request_id, + status_code) so callers no longer need to unpack anonymous tuples. + """ + + data: Dict[str, Any] + request_id: Optional[str] = None + status_code: int = 200 + + +# --------------------------------------------------------------------------- +# Constants (merged from _constants.py) +# --------------------------------------------------------------------------- + +HEADER_AUTHORIZATION = "Authorization" +HEADER_REQUEST_ID = "x-request-id" +HEADER_UID = "x-dashscope-uid" +HEADER_USER_AGENT = "User-Agent" + +# --------------------------------------------------------------------------- +# Response normalization helpers (merged from _response.py) +# --------------------------------------------------------------------------- + + +def unwrap(payload: Any) -> Tuple[Dict[str, Any], Optional[str]]: + """Normalize a JSON response body. + + Returns + ------- + (data, request_id) + ``data`` is the canonical resource payload with ``requestId`` + rewritten to ``request_id``. ``request_id`` is also returned as a + separate value for downstream metadata recording. + """ + + if not isinstance(payload, dict): + return ({"value": payload}, None) + + data = dict(payload) + request_id = data.pop("requestId", None) + if request_id is not None and "request_id" not in data: + data["request_id"] = request_id + rid = data.get("request_id") + return data, rid if isinstance(rid, str) else None + + +def is_error_payload(payload: Any) -> bool: + """Identify error bodies for both documented and pre-release shapes.""" + + if not isinstance(payload, dict): + return False + if payload.get("type") == "error": + return True + err = payload.get("error") + if isinstance(err, dict): + return any( + k in err + for k in ( + "code", + "message", + "error_code", + "error_message", + ) + ) + return False + + +def _resolve_timeout( + timeout: Union[float, httpx.Timeout, Tuple[float, float], None], +) -> httpx.Timeout: + if timeout is None: + return AGENTSTUDIO_DEFAULT_TIMEOUT + if isinstance(timeout, httpx.Timeout): + return timeout + if isinstance(timeout, tuple): + return httpx.Timeout(timeout[1], connect=timeout[0]) + return httpx.Timeout( + float(timeout), + connect=AGENTSTUDIO_DEFAULT_TIMEOUT.connect, + ) + + +def _backoff(attempt: int) -> float: + """Capped jittered exponential backoff.""" + + base = min(2**attempt, 8.0) + return base + random.random() * 0.25 + + +def _get_retry_after(headers: Mapping[str, str]) -> Optional[float]: + """Parse retry delay from response headers. + + Checks ``retry-after-ms`` (milliseconds), then ``retry-after`` (seconds). + Returns seconds or None. + """ + try: + ms = headers.get("retry-after-ms") + if ms is not None: + return float(ms) / 1000 + except (TypeError, ValueError): + pass + try: + sec = headers.get("retry-after") + if sec is not None: + return float(sec) + except (TypeError, ValueError): + pass + return None + + +def _should_retry(status_code: int, headers: Mapping[str, str]) -> bool: + """Decide whether an HTTP response should be retried.""" + should_retry_header = headers.get("x-should-retry") + if should_retry_header == "true": + return True + if should_retry_header == "false": + return False + return status_code in (408, 409, 429) or status_code >= 500 + + +def _should_retry_exception(err: BaseException) -> Tuple[bool, Optional[Any]]: + """Walk the ``__cause__`` chain to decide if an exception is retryable. + + Returns ``(should_retry, response_or_none)``. + """ + seen: set[int] = set() + current: Optional[BaseException] = err + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, exceptions.AgentStudioError): + return False, None + if isinstance(current, httpx.TimeoutException): + return True, None + if isinstance(current, httpx.NetworkError): + return True, None + current = current.__cause__ + return False, None + + +# --------------------------------------------------------------------------- +# Header builder (shared by sync + async transports) +# --------------------------------------------------------------------------- + + +def build_headers( + *, + api_key: Optional[str], + uid: Optional[str], + user_agent: str, + extra: Optional[Mapping[str, str]] = None, + json_body: bool = True, +) -> Dict[str, str]: + """Compose the canonical AgentStudio request headers.""" + + if not api_key: + raise exceptions.AuthenticationError( + "api_key is required. Pass it via Client(api_key=...) or " + "the DASHSCOPE_API_KEY environment variable.", + code="authentication_error", + ) + + headers: Dict[str, str] = { + HEADER_AUTHORIZATION: f"Bearer {api_key}", + HEADER_USER_AGENT: user_agent, + } + if json_body: + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + if uid: + headers[HEADER_UID] = uid + if extra: + for k, v in extra.items(): + if v is not None: + headers[k] = v + return headers + + +# --------------------------------------------------------------------------- +# Sync transport (httpx) +# --------------------------------------------------------------------------- + + +class SyncTransport: + """Thin ``httpx.Client`` wrapper used by the sync client.""" + + def __init__( + self, + *, + base_url: str, + api_key: Optional[str], + workspace: Optional[str], + uid: Optional[str], + user_agent: str, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None], + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.Client] = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.workspace = workspace + self.uid = uid + self.user_agent = user_agent + self.timeout = timeout + self.max_retries = max_retries + self._owns_client = http_client is None + if http_client is not None: + self._client = http_client + else: + socket_options = [ + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + ] + if hasattr(socket, "TCP_KEEPINTVL"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60), + ) + if hasattr(socket, "TCP_KEEPIDLE"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60), + ) + if hasattr(socket, "TCP_KEEPCNT"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5), + ) + transport = httpx.HTTPTransport( + socket_options=socket_options, + ) + self._client = httpx.Client( + timeout=_resolve_timeout(timeout), + limits=httpx.Limits( + max_connections=1000, + max_keepalive_connections=100, + ), + transport=transport, + ) + + def _absolute_url(self, path: str) -> str: + if path.startswith(("http://", "https://")): + return path + if not path.startswith("/"): + path = "/" + path + return self.base_url + path + + def request( # pylint: disable=too-many-branches + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Any = None, + data: Any = None, + files: Any = None, + extra_headers: Optional[Mapping[str, str]] = None, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None] = None, + stream: bool = False, + ) -> Any: + """Issue an HTTP request and return an :class:`APIResponse`. + + For ``stream=True`` returns the raw :class:`httpx.Response` so + the streaming module can iterate over it. + """ + + headers = build_headers( + api_key=self.api_key, + uid=self.uid, + user_agent=self.user_agent, + extra=extra_headers, + json_body=( + files is None + and method.upper() not in ("GET", "HEAD", "DELETE") + ), + ) + if files is not None: + headers.pop("Content-Type", None) + + url = self._absolute_url(path) + req_timeout = _resolve_timeout( + timeout if timeout is not None else self.timeout, + ) + + attempt = 0 + last_exc: Optional[Exception] = None + while attempt <= self.max_retries: + try: + req = self._client.build_request( + method=method.upper(), + url=url, + params=params, + json=json, + content=data, + files=files, + headers=headers, + timeout=req_timeout, + ) + resp = self._client.send(req, stream=stream) + except httpx.TimeoutException as exc: + last_exc = exc + if attempt >= self.max_retries: + raise exceptions.APITimeoutError(str(exc)) from exc + except Exception as exc: + if isinstance(exc, exceptions.AgentStudioError): + raise + should_retry, _ = _should_retry_exception(exc) + last_exc = exc + if attempt >= self.max_retries or not should_retry: + raise exceptions.APIConnectionError(str(exc)) from exc + else: + if stream: + if resp.status_code >= 400: + resp.read() + else: + return resp + if _should_retry( + resp.status_code, + resp.headers, + ): + last_exc = exceptions.APIStatusError( + f"HTTP {resp.status_code}", + status_code=resp.status_code, + ) + if attempt < self.max_retries: + wait = _get_retry_after(resp.headers) or _backoff( + attempt, + ) + resp.close() + time.sleep(wait) + attempt += 1 + continue + try: + return self._parse(resp) + finally: + resp.close() + time.sleep(_backoff(attempt)) + attempt += 1 + raise exceptions.APIConnectionError( + str(last_exc) if last_exc else "unknown", + ) + + def _parse(self, resp: httpx.Response) -> APIResponse: + header_rid = resp.headers.get("x-request-id") + ctype = resp.headers.get("Content-Type", "") + + if not ctype.startswith("application/json") and resp.status_code < 400: + payload: Any = {"_binary": resp.content, "_content_type": ctype} + data, rid = unwrap(payload) + return APIResponse( + data=data, + request_id=rid or header_rid, + status_code=resp.status_code, + ) + + try: + payload = resp.json() + except ValueError: + payload = {"raw": resp.text} + + if resp.status_code >= 400 or is_error_payload(payload): + logger.warning( + "AgentStudio request failed: status=%s request_id=%s body=%s", + resp.status_code, + header_rid, + str(payload)[:500], + ) + raise exceptions.from_response( + status_code=resp.status_code, + body=payload, + headers=resp.headers, + ) + + data, rid = unwrap(payload) + return APIResponse( + data=data, + request_id=rid or header_rid, + status_code=resp.status_code, + ) + + def close(self) -> None: + if self._owns_client: + self._client.close() + + @property + def is_closed(self) -> bool: + return self._client.is_closed + + def __enter__(self) -> "SyncTransport": + return self + + def __exit__( + self, + exc_type: Optional[type], + exc: Optional[BaseException], + exc_tb: Any, + ) -> None: + self.close() + + def __del__(self) -> None: + try: + if self.is_closed: + return + self.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Async transport (httpx) +# --------------------------------------------------------------------------- + + +class AsyncTransport: + """``httpx.AsyncClient`` transport with built-in connection pooling.""" + + def __init__( + self, + *, + base_url: str, + api_key: Optional[str], + workspace: Optional[str], + uid: Optional[str], + user_agent: str, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None], + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.AsyncClient] = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.workspace = workspace + self.uid = uid + self.user_agent = user_agent + self.timeout = timeout + self.max_retries = max_retries + self._owns_client = http_client is None + if http_client is not None: + self._client = http_client + else: + socket_options = [ + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + ] + if hasattr(socket, "TCP_KEEPINTVL"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60), + ) + if hasattr(socket, "TCP_KEEPIDLE"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60), + ) + if hasattr(socket, "TCP_KEEPCNT"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5), + ) + transport = httpx.AsyncHTTPTransport( + socket_options=socket_options, + ) + self._client = httpx.AsyncClient( + timeout=_resolve_timeout(timeout), + limits=httpx.Limits( + max_connections=1000, + max_keepalive_connections=100, + ), + transport=transport, + ) + + def _absolute_url(self, path: str) -> str: + if path.startswith(("http://", "https://")): + return path + if not path.startswith("/"): + path = "/" + path + return self.base_url + path + + async def request( # pylint: disable=too-many-branches + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Any = None, + data: Any = None, + files: Any = None, + extra_headers: Optional[Mapping[str, str]] = None, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None] = None, + stream: bool = False, + ) -> Any: + """Issue an HTTP request. + + For ``stream=True`` returns raw ``httpx.Response``. + """ + + headers = build_headers( + api_key=self.api_key, + uid=self.uid, + user_agent=self.user_agent, + extra=extra_headers, + json_body=( + files is None + and method.upper() not in ("GET", "HEAD", "DELETE") + ), + ) + if files is not None: + headers.pop("Content-Type", None) + req_timeout = _resolve_timeout( + timeout if timeout is not None else self.timeout, + ) + url = self._absolute_url(path) + + attempt = 0 + last_exc: Optional[Exception] = None + while attempt <= self.max_retries: + try: + req = self._client.build_request( + method=method.upper(), + url=url, + params=params, + json=json, + content=data, + files=files, + headers=headers, + timeout=req_timeout, + ) + resp = await self._client.send(req, stream=stream) + except httpx.TimeoutException as exc: + last_exc = exc + if attempt >= self.max_retries: + raise exceptions.APITimeoutError(str(exc)) from exc + except Exception as exc: + if isinstance(exc, exceptions.AgentStudioError): + raise + should_retry, _ = _should_retry_exception(exc) + last_exc = exc + if attempt >= self.max_retries or not should_retry: + raise exceptions.APIConnectionError(str(exc)) from exc + else: + if stream: + if resp.status_code >= 400: + await resp.aread() + else: + return resp + if _should_retry( + resp.status_code, + resp.headers, + ): + last_exc = exceptions.APIStatusError( + f"HTTP {resp.status_code}", + status_code=resp.status_code, + ) + if attempt < self.max_retries: + wait = _get_retry_after(resp.headers) or _backoff( + attempt, + ) + await resp.aclose() + await asyncio.sleep(wait) + attempt += 1 + continue + try: + return await self._parse(resp) + finally: + await resp.aclose() + await asyncio.sleep(_backoff(attempt)) + attempt += 1 + raise exceptions.APIConnectionError( + str(last_exc) if last_exc else "unknown", + ) + + async def _parse(self, resp: httpx.Response) -> APIResponse: + header_rid = resp.headers.get("x-request-id") + ctype = resp.headers.get("Content-Type", "") + + if not ctype.startswith("application/json") and resp.status_code < 400: + content = await resp.aread() + data, rid = unwrap({"_binary": content, "_content_type": ctype}) + return APIResponse( + data=data, + request_id=rid or header_rid, + status_code=resp.status_code, + ) + + try: + payload = resp.json() + except ValueError: + payload = {"raw": resp.text} + + if resp.status_code >= 400 or is_error_payload(payload): + logger.warning( + "AgentStudio request failed: status=%s request_id=%s body=%s", + resp.status_code, + header_rid, + str(payload)[:500], + ) + raise exceptions.from_response( + status_code=resp.status_code, + body=payload, + headers=resp.headers, + ) + + data, rid = unwrap(payload) + return APIResponse( + data=data, + request_id=rid or header_rid, + status_code=resp.status_code, + ) + + def close(self) -> None: + """No-op: AsyncClient requires async cleanup via aclose().""" + + async def aclose(self) -> None: + if self._owns_client: + await self._client.aclose() + + @property + def is_closed(self) -> bool: + return self._client.is_closed + + async def __aenter__(self) -> "AsyncTransport": + return self + + async def __aexit__( + self, + exc_type: Optional[type], + exc: Optional[BaseException], + exc_tb: Any, + ) -> None: + await self.aclose() + + def __del__(self) -> None: + if self.is_closed: + return + try: + asyncio.get_running_loop().create_task(self.aclose()) + except RuntimeError: + self.close() + except Exception: + pass diff --git a/dashscope/agentstudio/types/__init__.py b/dashscope/agentstudio/types/__init__.py new file mode 100644 index 0000000..fda94eb --- /dev/null +++ b/dashscope/agentstudio/types/__init__.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +"""AgentStudio type definitions. + +Re-exports all public symbols from sub-modules for backward compatibility: + from dashscope.agentstudio.types import Agent, Message, user_message +""" +from dashscope.agentstudio.types.models import * # noqa: F401,F403 +from dashscope.agentstudio.types.models import ( # noqa: F401 + BaseModel, + Metadata, + Scope, + Mount, + Networking, + Packages, + EnvironmentConfig, + StopReason, + Stats, + Usage, + TextBlock, + ImageBlock, + AudioBlock, + DataBlock, + FileBlock, + RefusalBlock, + ErrorBlock, + ContentBlock, + parse_content_block, + parse_content_blocks, + Agent, + AgentVersion, + Credential, + CredentialAuth, + Environment, + File, + Skill, + SkillVersion, + Session, + Vault, + SessionThread, + DeleteResponse, + Message, + parse_message, + parse_server_event, + user_message, + user_interrupt, + user_tool_confirmation, + user_custom_tool_result, + user_tool_result, + user_define_outcome, + ServerEvent, + UnknownServerEvent, + AgentMessageEvent, + AgentThinkingEvent, + AgentToolUseEvent, + AgentToolResultEvent, + AgentCustomToolUseEvent, + AgentCustomToolResultEvent, + AgentMcpToolUseEvent, + AgentMcpToolResultEvent, + AgentThreadMessageSentEvent, + AgentThreadMessageReceivedEvent, + AgentThreadContextCompactedEvent, + SessionStatusRunningEvent, + SessionStatusIdleEvent, + SessionStatusReschedulingEvent, + SessionStatusTerminatedEvent, + SessionErrorEvent, + SessionUpdatedEvent, + SessionThreadCreatedEvent, + SessionThreadStatusEvent, + SpanModelRequestStartEvent, + SpanModelRequestEndEvent, +) +from dashscope.agentstudio.types.params import ( # noqa: F401 + AgentCreateParams, + AgentUpdateParams, + AgentListParams, + EnvironmentCreateParams, + EnvironmentUpdateParams, + EnvironmentListParams, + SessionCreateParams, + SessionUpdateParams, + SessionListParams, + SessionEventSendParams, + SessionEventListParams, + FileListParams, + SkillCreateParams, + SkillListParams, + SkillVersionCreateParams, + SkillVersionListParams, + VaultCreateParams, + VaultUpdateParams, + VaultListParams, + CredentialCreateParams, + CredentialUpdateParams, + CredentialListParams, +) diff --git a/dashscope/agentstudio/types/models.py b/dashscope/agentstudio/types/models.py new file mode 100644 index 0000000..3dd3dc9 --- /dev/null +++ b/dashscope/agentstudio/types/models.py @@ -0,0 +1,870 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Unified type definitions for the AgentStudio SDK. + +This module merges all types from the ``types/`` sub-package into a single +file organized into six sections: + + 1. Common types (EnvironmentConfig, Usage, Metadata, …) + 2. Content blocks (TextBlock, ImageBlock, … ContentBlock) + 3. Resource objects (Agent, Session, File, Skill, …) + 4. Unified Message (replaces 20+ individual server-event classes) + 5. Client event helpers (user_message, user_interrupt, …) + 6. Backward compatibility aliases +""" + +from __future__ import annotations + +from typing import ( + Any, + ClassVar, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, + Union, +) + +from dashscope.agentstudio.constants import ( + SSEEventType, + MessageRole, + BlockType, +) + + +# =========================================================================== +# Section 0 – Base model +# =========================================================================== + + +class BaseModel: + """Lightweight typed container. + + Subclasses declare the attributes they expect via :attr:`_fields` so + :meth:`to_dict` can emit a deterministic ordering. Extra fields are + preserved in :attr:`extra` to maintain forward compatibility when the + server adds new fields. + """ + + _fields: ClassVar[Iterable[str]] = () + + def __init__(self, **kwargs: Any) -> None: + self._raw: Dict[str, Any] = dict(kwargs) + for name in self._fields: + setattr(self, name, kwargs.get(name)) + self.extra: Dict[str, Any] = { + k: v for k, v in kwargs.items() if k not in set(self._fields) + } + + @classmethod + def from_dict( + cls, + payload: Optional[Mapping[str, Any]], + ) -> Optional["BaseModel"]: + if payload is None: + return None + return cls(**dict(payload)) + + def to_dict(self) -> Dict[str, Any]: + out: Dict[str, Any] = {} + for name in self._fields: + value = getattr(self, name, None) + if value is None: + continue + out[name] = _serialize(value) + for k, v in self.extra.items(): + if v is None: + continue + out[k] = _serialize(v) + return out + + def __repr__(self) -> str: # pragma: no cover - debug helper + body = ", ".join( + f"{name}={getattr(self, name)!r}" + for name in self._fields + if getattr(self, name, None) is not None + ) + return f"{type(self).__name__}({body})" + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, BaseModel): + return NotImplemented + return self.to_dict() == other.to_dict() + + +def _serialize(value: Any) -> Any: + if isinstance(value, BaseModel): + return value.to_dict() + if isinstance(value, list): + return [_serialize(v) for v in value] + if isinstance(value, dict): + return {k: _serialize(v) for k, v in value.items()} + return value + + +# =========================================================================== +# Section 1 – Common types +# =========================================================================== + + +class Metadata(BaseModel): + """Free-form metadata bag attached to most AgentStudio resources. + + The server stores ``metadata`` as a flat string-keyed dictionary + (max 16 keys, key length <= 64, value length <= 512). The SDK does + not validate these limits client-side – the server returns + ``invalid_request_error`` if they are exceeded. + """ + + def to_dict(self) -> Dict[str, Any]: + return dict(self._raw) + + +class Scope(BaseModel): + """Resource scope (organization / session).""" + + _fields = ("type", "id") + + +class Mount(BaseModel): + """Session resource mount descriptor.""" + + _fields = ("type", "file_id", "skill_id", "mount_path") + + +class Networking(BaseModel): + _fields = ("type",) # "unrestricted" | "restricted" + + +class Packages(BaseModel): + _fields = ("apt", "gem", "pip", "cargo", "go", "npm") + + +class EnvironmentConfig(BaseModel): + _fields = ("type", "networking", "packages") + + def __init__(self, **kwargs: Any) -> None: + if "networking" in kwargs and isinstance(kwargs["networking"], dict): + kwargs["networking"] = Networking(**kwargs["networking"]) + if "packages" in kwargs and isinstance(kwargs["packages"], dict): + kwargs["packages"] = Packages(**kwargs["packages"]) + super().__init__(**kwargs) + + +class StopReason(BaseModel): + """Carried by ``session_status`` idle events.""" + + _fields = ("type", "event_ids") + + +class Stats(BaseModel): + """Session runtime statistics.""" + + _fields = ("active_seconds", "duration_seconds") + + +class Usage(BaseModel): + """Token usage carried by ``span.model_request_end`` events.""" + + _fields = ( + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + "cache_creation", + "speed", + ) + + +# =========================================================================== +# Section 2 – Content blocks +# =========================================================================== + + +class TextBlock(BaseModel): + _fields = ("type", "text", "citations") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", BlockType.TEXT) + super().__init__(**kwargs) + + def __str__(self) -> str: + return getattr(self, "text", "") or "" + + +class ImageBlock(BaseModel): + _fields = ("type", "image_url", "file_id", "image_data", "media_type") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "image") + super().__init__(**kwargs) + + def __str__(self) -> str: + url = getattr(self, "image_url", "") or "" + fid = getattr(self, "file_id", "") or "" + return f"[image] {url or fid}" + + +class AudioBlock(BaseModel): + _fields = ("type", "data", "format", "file_id") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "audio") + super().__init__(**kwargs) + + def __str__(self) -> str: + return f"[audio] {getattr(self, 'file_id', '') or ''}" + + +class DataBlock(BaseModel): + _fields = ("type", "data", "name", "title", "context") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "data") + super().__init__(**kwargs) + + def __str__(self) -> str: + d = getattr(self, "data", None) or {} + if not isinstance(d, dict): + return str(d) + name = d.get("name", "") + args = d.get("arguments", "") + output = d.get("output", "") + status = d.get("session_status", "") + if name and args: + return f"{name}({args})" + if output: + return str(output) + if status: + return status + return str(d) if d else "" + + +class FileBlock(BaseModel): + _fields = ( + "type", + "file_url", + "file_id", + "file_data", + "media_type", + "filename", + ) + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "file") + super().__init__(**kwargs) + + def __str__(self) -> str: + name = getattr(self, "filename", "") or "" + fid = getattr(self, "file_id", "") or "" + return f"[file] {name or fid}" + + +class RefusalBlock(BaseModel): + _fields = ("type", "refusal") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "refusal") + super().__init__(**kwargs) + + def __str__(self) -> str: + return f"[refusal] {getattr(self, 'refusal', '')}" + + +class ErrorBlock(BaseModel): + _fields = ("type", "error_code", "message") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", BlockType.ERROR) + super().__init__(**kwargs) + + def __str__(self) -> str: + msg = getattr(self, "message", "") or "" + code = getattr(self, "error_code", "") or "" + return f"[error] {msg or code}" + + +_CONTENT_REGISTRY: Dict[str, type] = { + BlockType.TEXT: TextBlock, + BlockType.IMAGE: ImageBlock, + BlockType.AUDIO: AudioBlock, + BlockType.DATA: DataBlock, + BlockType.FILE: FileBlock, + BlockType.REFUSAL: RefusalBlock, + BlockType.ERROR: ErrorBlock, + SSEEventType.TOOL_CALL: DataBlock, + SSEEventType.TOOL_CALL_OUTPUT: DataBlock, + SSEEventType.SESSION_STATUS: DataBlock, + SSEEventType.REASONING: DataBlock, + SSEEventType.MCP_CALL: DataBlock, + SSEEventType.MCP_CALL_OUTPUT: DataBlock, + SSEEventType.FUNCTION_CALL: DataBlock, + SSEEventType.FUNCTION_CALL_OUTPUT: DataBlock, +} + +ContentBlock = Union[ + TextBlock, + ImageBlock, + AudioBlock, + DataBlock, + FileBlock, + RefusalBlock, + ErrorBlock, +] + + +def parse_content_block(payload: Mapping[str, Any]) -> ContentBlock: + cls = _CONTENT_REGISTRY.get(payload.get("type", ""), TextBlock) + return cls(**dict(payload)) + + +def parse_content_blocks( + items: Optional[List[Mapping[str, Any]]], +) -> List[ContentBlock]: + if not items: + return [] + return [parse_content_block(it) for it in items if isinstance(it, Mapping)] + + +# =========================================================================== +# Section 3 – Resource objects +# =========================================================================== + + +class Agent(BaseModel): + _fields = ( + "id", + "type", + "version", + "name", + "description", + "model", + "system", + "tools", + "mcp_servers", + "skills", + "metadata", + "workspace_id", + "archived_at", + "created_at", + "updated_at", + "request_id", + ) + + @property + def system_prompt(self) -> Optional[str]: + """Alias: server field is ``system``, kept for SDK user convenience.""" + return self.system + + +class AgentVersion(BaseModel): + _fields = ( + "agent_id", + "version", + "config", + "created_at", + ) + + +class Environment(BaseModel): + _fields = ( + "id", + "type", + "name", + "description", + "config", + "metadata", + "scope", + "archived_at", + "created_at", + "updated_at", + "request_id", + ) + + def __init__(self, **kwargs: Any) -> None: + cfg = kwargs.get("config") + if isinstance(cfg, Mapping): + kwargs["config"] = EnvironmentConfig(**dict(cfg)) + super().__init__(**kwargs) + + +class File(BaseModel): + _fields = ( + "id", + "type", + "filename", + "downloadable", + "mime_type", + "size_bytes", + "status", + "created_at", + "request_id", + ) + + +class Skill(BaseModel): + _fields = ( + "id", + "type", + "name", + "description", + "source", + "status", + "latest_version", + "version", # backward compat alias + "file_id", + "scope", + "created_at", + "updated_at", + "request_id", + ) + + +class SkillVersion(BaseModel): + _fields = ( + "id", + "type", + "skill_id", + "name", + "description", + "version", + "status", + "additional_properties", + "created_at", + "updated_at", + ) + + +class Session(BaseModel): + _fields = ( + "id", + "type", + "title", + "agent", + "environment_id", + "status", + "stop_reason", + "resources", + "metadata", + "stats", + "usage", + "vault_ids", + "archived_at", + "created_at", + "updated_at", + "request_id", + ) + + def __init__(self, **kwargs: Any) -> None: + agent = kwargs.get("agent") + if isinstance(agent, Mapping): + kwargs["agent"] = Agent(**dict(agent)) + sr = kwargs.get("stop_reason") + if isinstance(sr, Mapping): + kwargs["stop_reason"] = StopReason(**dict(sr)) + usage = kwargs.get("usage") + if isinstance(usage, Mapping): + kwargs["usage"] = Usage(**dict(usage)) + stats = kwargs.get("stats") + if isinstance(stats, Mapping): + kwargs["stats"] = Stats(**dict(stats)) + super().__init__(**kwargs) + + @property + def agent_id(self) -> Optional[str]: + a = self.agent + if isinstance(a, Agent): + return a.id + return a if isinstance(a, str) else None + + @property + def agent_version(self) -> Optional[int]: + a = self.agent + return a.version if isinstance(a, Agent) else None + + +class SessionThread(BaseModel): + _fields = ( + "id", + "session_id", + "parent_thread_id", + "title", + "status", + "created_at", + "updated_at", + ) + + +class DeleteResponse(BaseModel): + _fields = ("id", "type", "request_id") + + +# =========================================================================== +# Section 4 – Unified Message class (replaces server_events.py) +# =========================================================================== + + +class Message(BaseModel): + """Unified event/message model corresponding to the AgentStudio API + protocol Message structure. + + All SSE stream events and history events share this structure. + The ``type`` field discriminates between event kinds:: + + message, tool_call, tool_call_output, function_call, + function_call_output, mcp_call, mcp_call_output, reasoning, + session_status, error, session_updated, thread_created, + thread_status, thread_message_sent, thread_message_received, + thread_context_compacted, model_request_start, model_request_end, + outcome_evaluation, interrupt, tool_confirmation, define_outcome + + Legacy ``agent.*`` / ``session.*`` / ``span.*`` type strings are also + accepted transparently for backward compatibility. + """ + + _fields = ( + "object", + "status", + "id", + "type", + "role", + "content", + "metadata", + "is_error", + "created_at", + "sequence_number", + "session_thread_id", + "code", + "message", + ) + + def __init__(self, **kwargs: Any) -> None: + content = kwargs.get("content") + if isinstance(content, list): + kwargs["content"] = parse_content_blocks(content) + super().__init__(**kwargs) + + def __str__(self) -> str: + etype = getattr(self, "type", "unknown") + content = self.content or [] + if content: + return f"[{etype}] " + " ".join(str(b) for b in content) + return f"[{etype}]" + + @property + def stop_reason(self) -> Optional[Dict[str, Any]]: + """``stop_reason`` carried by ``session_status`` idle events. + + The server puts ``stop_reason`` inside the ``session_status`` event's + data block (not in ``session.retrieve()``). Returns a dict like + ``{"type": "end_turn"}`` or ``None`` for non-session_status events. + """ + if getattr(self, "type", None) != SSEEventType.SESSION_STATUS: + return None + for block in self.content or []: + d = getattr(block, "data", None) + if isinstance(d, dict) and "stop_reason" in d: + return d["stop_reason"] + return None + + @property + def session_status(self) -> Optional[str]: + """``session_status`` value from ``session_status`` events. + + Returns ``"idle"``, ``"running"``, ``"rescheduling"``, + ``"terminated"`` or ``None`` for non-session_status events. + """ + if getattr(self, "type", None) != SSEEventType.SESSION_STATUS: + return None + for block in self.content or []: + d = getattr(block, "data", None) + if isinstance(d, dict) and "session_status" in d: + return d["session_status"] + return None + + +def parse_message(payload: Mapping[str, Any]) -> Message: + """Turn a parsed SSE ``data`` dict into a :class:`Message` instance.""" + return Message(**dict(payload)) + + +# Keep the original function name as an alias so existing code that calls +# ``parse_server_event`` continues to work unchanged. +def parse_server_event(payload: Mapping[str, Any]) -> Message: + """Alias for :func:`parse_message`; kept for backward compatibility.""" + return parse_message(payload) + + +# =========================================================================== +# Section 5 – Client event helpers (AGENTSTUDIO wire format) +# =========================================================================== + + +def user_message( + text_or_blocks: Any, + *, + session_thread_id: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + """Build a ``message`` client event (AGENTSTUDIO wire format). + + ``text_or_blocks`` may be a plain string (wrapped as a text block) + or a list of pre-built content block dicts. + """ + + if isinstance(text_or_blocks, str): + content: List[Dict[str, Any]] = [ + {"type": BlockType.TEXT, "text": text_or_blocks}, + ] + else: + content = list(text_or_blocks) + evt: Dict[str, Any] = { + "role": MessageRole.USER, + "type": SSEEventType.MESSAGE, + "content": content, + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + if metadata: + evt["metadata"] = dict(metadata) + return evt + + +def user_interrupt( + *, + session_thread_id: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + """Cancel the agent's current turn (best-effort).""" + + evt: Dict[str, Any] = { + "role": MessageRole.USER, + "type": SSEEventType.INTERRUPT, + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + if metadata: + evt["metadata"] = dict(metadata) + return evt + + +def user_tool_confirmation( + *, + tool_use_id: str, + result: str, + deny_message: Optional[str] = None, + session_thread_id: Optional[str] = None, +) -> Dict[str, Any]: + """Approve or deny a built-in tool invocation. + + ``result`` must be ``"allow"`` or ``"deny"``. ``deny_message`` is + only meaningful when denying. + """ + + if result not in ("allow", "deny"): + raise ValueError("tool_confirmation result must be 'allow' or 'deny'") + data: Dict[str, Any] = { + "call_id": tool_use_id, + "result": result, + } + if deny_message and result == "deny": + data["deny_message"] = deny_message + evt: Dict[str, Any] = { + "role": MessageRole.USER, + "type": SSEEventType.TOOL_CONFIRMATION, + "content": [{"type": "data", "data": data}], + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + return evt + + +def user_custom_tool_result( + *, + custom_tool_use_id: str, + content: Any, + is_error: bool = False, + session_thread_id: Optional[str] = None, +) -> Dict[str, Any]: + """Reply to an :class:`AgentCustomToolUseEvent` with a tool result. + + Emits a ``function_call_output`` event in the AGENTSTUDIO wire format. + """ + + if isinstance(content, str): + output = content + elif isinstance(content, Sequence) and not isinstance(content, str): + output = list(content) + else: + output = content + evt: Dict[str, Any] = { + "role": MessageRole.TOOL, + "type": SSEEventType.FUNCTION_CALL_OUTPUT, + "content": [ + { + "type": "data", + "data": { + "call_id": custom_tool_use_id, + "output": output, + }, + }, + ], + "is_error": bool(is_error), + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + return evt + + +def user_tool_result( + *, + tool_use_id: str, + content: Any, + is_error: bool = False, + session_thread_id: Optional[str] = None, +) -> Dict[str, Any]: + """Self-hosted tool result (advanced; mirrors built-in tool execution). + + Emits a ``tool_call_output`` event in the AGENTSTUDIO wire format. + """ + + if isinstance(content, str): + output = content + elif isinstance(content, Sequence) and not isinstance(content, str): + output = list(content) + else: + output = content + evt: Dict[str, Any] = { + "role": MessageRole.TOOL, + "type": SSEEventType.TOOL_CALL_OUTPUT, + "content": [ + { + "type": "data", + "data": { + "call_id": tool_use_id, + "output": output, + }, + }, + ], + "is_error": bool(is_error), + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + return evt + + +def user_define_outcome( + *, + description: str, + rubric: Optional[str] = None, + max_iterations: Optional[int] = None, + session_thread_id: Optional[str] = None, +) -> Dict[str, Any]: + """Steer the agent towards a measurable outcome.""" + + data: Dict[str, Any] = {"description": description} + if rubric is not None: + data["rubric"] = rubric + if max_iterations is not None: + data["max_iterations"] = int(max_iterations) + evt: Dict[str, Any] = { + "role": MessageRole.USER, + "type": SSEEventType.DEFINE_OUTCOME, + "content": [{"type": "data", "data": data}], + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + return evt + + +# =========================================================================== +# Section 6 – Vaults & Credentials +# =========================================================================== + + +class CredentialAuth(BaseModel): + _fields = ( + "type", + "access_token", + "mcp_server_url", + "expires_at", + "refresh", + "token", + "secret_name", + "secret_value", + "networking", + ) + + def __init__(self, **kwargs: Any) -> None: + net = kwargs.get("networking") + if isinstance(net, dict): + kwargs["networking"] = Networking(**net) + super().__init__(**kwargs) + + +class Vault(BaseModel): + _fields = ( + "id", + "archived_at", + "created_at", + "display_name", + "metadata", + "type", + "updated_at", + "request_id", + ) + + +class Credential(BaseModel): + _fields = ( + "id", + "archived_at", + "auth", + "created_at", + "display_name", + "metadata", + "type", + "updated_at", + "vault_id", + "request_id", + ) + + def __init__(self, **kwargs: Any) -> None: + auth = kwargs.get("auth") + if isinstance(auth, dict): + kwargs["auth"] = CredentialAuth(**auth) + super().__init__(**kwargs) + + +# =========================================================================== +# Section 7 – Backward compatibility aliases +# =========================================================================== + +# server_events.py ── ServerEvent was the base class; now it's Message +ServerEvent = Message +UnknownServerEvent = Message + +# All the concrete subclasses from server_events.py map to Message +AgentMessageEvent = Message +AgentThinkingEvent = Message +AgentToolUseEvent = Message +AgentToolResultEvent = Message +AgentCustomToolUseEvent = Message +AgentCustomToolResultEvent = Message +AgentMcpToolUseEvent = Message +AgentMcpToolResultEvent = Message +AgentThreadMessageSentEvent = Message +AgentThreadMessageReceivedEvent = Message +AgentThreadContextCompactedEvent = Message +SessionStatusRunningEvent = Message +SessionStatusIdleEvent = Message +SessionStatusReschedulingEvent = Message +SessionStatusTerminatedEvent = Message +SessionErrorEvent = Message +SessionUpdatedEvent = Message +SessionThreadCreatedEvent = Message +SessionThreadStatusEvent = Message +SpanModelRequestStartEvent = Message +SpanModelRequestEndEvent = Message diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py new file mode 100644 index 0000000..5767921 --- /dev/null +++ b/dashscope/agentstudio/types/params.py @@ -0,0 +1,551 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Per-resource API request parameter definitions. + +Each resource group (Agents, Environments, Sessions, …) defines the +request-body structures for its write endpoints. All classes inherit +from :class:`~dashscope.agentstudio.types.models.BaseModel` so that +``.to_dict()`` produces a ready-to-send wire-format dict. + +The ``__init__`` signature uses **user-friendly** parameter names +(e.g. ``system_prompt``) while the ``super().__init__()`` call passes +**wire-format** field names (e.g. ``system``). + +Python 3.8+ compatible. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Sequence + +from dashscope.agentstudio.types.models import BaseModel + + +# =========================================================================== +# Agents +# =========================================================================== + + +class AgentCreateParams(BaseModel): + """Request body for ``POST /agents``. + + Maps to :meth:`Agents.create`. The SDK method exposes + ``model`` as a plain string and ``system_prompt`` as a keyword arg, + but the wire format uses ``model: {"id": "…"}`` and ``system``. + """ + + _fields = ( + "name", + "model", + "description", + "system", + "tools", + "mcp_servers", + "skills", + "metadata", + ) + + def __init__( + self, + *, + name: str, + model: str, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + name=name, + model={"id": model}, + description=description, + system=system_prompt, + tools=([dict(t) for t in tools] if tools is not None else None), + mcp_servers=( + [dict(s) for s in mcp_servers] + if mcp_servers is not None + else None + ), + skills=([dict(s) for s in skills] if skills is not None else None), + metadata=(dict(metadata) if metadata is not None else None), + ) + + +class AgentUpdateParams(BaseModel): + """Request body for ``POST /agents/{id}`` (update latest version).""" + + _fields = ( + "version", + "name", + "model", + "description", + "system", + "tools", + "mcp_servers", + "skills", + "metadata", + ) + + def __init__( + self, + *, + version: int, + name: Optional[str] = None, + model: Optional[str] = None, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + version=version, + name=name, + model=({"id": model} if model is not None else None), + description=description, + system=system_prompt, + tools=([dict(t) for t in tools] if tools is not None else None), + mcp_servers=( + [dict(s) for s in mcp_servers] + if mcp_servers is not None + else None + ), + skills=([dict(s) for s in skills] if skills is not None else None), + metadata=(dict(metadata) if metadata is not None else None), + ) + + +class AgentListParams(BaseModel): + """Query params for ``GET /agents``. + + ``include_archived`` is a Python bool mapped to the wire-format + string ``"true"`` / ``"false"``. + """ + + _fields = ("limit", "page", "include_archived") + + def __init__( # pylint: disable=useless-parent-delegation + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> None: + archived = ( + str(include_archived).lower() + if include_archived is not None + else None + ) + super().__init__( + limit=limit, + page=page, + include_archived=archived, + ) + + +class AgentVersionListParams(BaseModel): + """Query params for ``GET /agents/{id}/versions``.""" + + _fields = ("limit", "page") + + +# =========================================================================== +# Environments +# =========================================================================== + + +class EnvironmentCreateParams(BaseModel): + """Request body for ``POST /environments``.""" + + _fields = ("name", "config", "description", "scope", "metadata") + + def __init__( # pylint: disable=useless-parent-delegation + self, + *, + name: str, + config: Mapping[str, Any], + description: Optional[str] = None, + scope: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + name=name, + config=dict(config), + description=description, + scope=scope, + metadata=(dict(metadata) if metadata is not None else None), + ) + + +class EnvironmentUpdateParams(BaseModel): + """Request body for ``POST /environments/{id}``.""" + + _fields = ("name", "description", "config", "scope", "metadata") + + def __init__( # pylint: disable=useless-parent-delegation + self, + *, + name: Optional[str] = None, + description: Optional[str] = None, + config: Optional[Mapping[str, Any]] = None, + scope: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + name=name, + description=description, + config=(dict(config) if config is not None else None), + scope=scope, + metadata=(dict(metadata) if metadata is not None else None), + ) + + +class EnvironmentListParams(BaseModel): + """Query params for ``GET /environments``. + + ``include_archived`` is a Python bool mapped to the wire-format + string ``"true"`` / ``"false"``. + """ + + _fields = ("limit", "page", "include_archived") + + def __init__( # pylint: disable=useless-parent-delegation + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> None: + archived = ( + str(include_archived).lower() + if include_archived is not None + else None + ) + super().__init__( + limit=limit, + page=page, + include_archived=archived, + ) + + +# =========================================================================== +# Sessions +# =========================================================================== + + +class SessionCreateParams(BaseModel): + """Request body for ``POST /sessions``. + + ``agent`` is the agent ID string (not the full agent object). + ``resources`` is an optional list of file mounts; each item is a + mapping with ``type``, ``file_id`` and ``mount_path`` keys. + """ + + _fields = ("agent", "environment_id", "title", "resources", "metadata") + + def __init__( # pylint: disable=useless-parent-delegation + self, + *, + agent: str, + environment_id: Optional[str] = None, + title: Optional[str] = None, + resources: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + agent=agent, + environment_id=environment_id, + title=title, + resources=( + [dict(r) for r in resources] if resources is not None else None + ), + metadata=(dict(metadata) if metadata is not None else None), + ) + + +class SessionUpdateParams(BaseModel): + """Request body for ``POST /sessions/{id}``.""" + + _fields = ("title", "metadata") + + def __init__( # pylint: disable=useless-parent-delegation + self, + *, + title: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + title=title, + metadata=(dict(metadata) if metadata is not None else None), + ) + + +class SessionListParams(BaseModel): + """Query params for ``GET /sessions``. + + ``statuses`` is a list of session status strings such as + ``"idle"``, ``"running"``, ``"terminated"``. + + Python-friendly parameter names (``statuses``, ``created_at_gt``, …) + are mapped to wire-format keys (``statuses[]``, ``created_at[gt]``, …) + inside ``__init__``. + """ + + _fields = ( + "limit", + "page", + "agent_id", + "statuses[]", + "created_at[gt]", + "created_at[gte]", + "created_at[lt]", + "created_at[lte]", + ) + + def __init__( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + agent_id: Optional[str] = None, + statuses: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + ) -> None: + kwargs = {} + kwargs["limit"] = limit + kwargs["page"] = page + kwargs["agent_id"] = agent_id + if statuses is not None: + kwargs["statuses[]"] = list(statuses) + if created_at_gt is not None: + kwargs["created_at[gt]"] = created_at_gt + if created_at_gte is not None: + kwargs["created_at[gte]"] = created_at_gte + if created_at_lt is not None: + kwargs["created_at[lt]"] = created_at_lt + if created_at_lte is not None: + kwargs["created_at[lte]"] = created_at_lte + super().__init__(**kwargs) + + +# =========================================================================== +# SessionEvents +# =========================================================================== + + +class SessionEventSendParams(BaseModel): + """Request body for ``POST /sessions/{id}/events``. + + The ``input`` array must contain at least one event. + """ + + _fields = ("input",) + + def __init__( # pylint: disable=useless-parent-delegation + self, + *, + input: Sequence[ # pylint: disable=redefined-builtin + Mapping[str, Any] + ], + ) -> None: + super().__init__( + input=[dict(e) for e in input], + ) + + +class SessionEventListParams(BaseModel): + """Query params for ``GET /sessions/{id}/events``. + + ``types`` is a list of event type strings joined by comma in the + wire format. ``created_at_*`` parameters are mapped to the + bracket-based wire keys ``created_at[gt]`` etc. + """ + + _fields = ( + "types", + "created_at[gt]", + "created_at[gte]", + "created_at[lt]", + "created_at[lte]", + "limit", + "order", + "page", + ) + + def __init__( + self, + *, + types: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + limit: Optional[int] = None, + order: Optional[str] = None, + page: Optional[str] = None, + ) -> None: + kwargs = {} + if types is not None: + kwargs["types"] = ",".join(types) + if created_at_gt is not None: + kwargs["created_at[gt]"] = created_at_gt + if created_at_gte is not None: + kwargs["created_at[gte]"] = created_at_gte + if created_at_lt is not None: + kwargs["created_at[lt]"] = created_at_lt + if created_at_lte is not None: + kwargs["created_at[lte]"] = created_at_lte + kwargs["limit"] = limit + kwargs["order"] = order + kwargs["page"] = page + super().__init__(**kwargs) + + +# =========================================================================== +# Files +# =========================================================================== + + +class FileListParams(BaseModel): + """Query params for ``GET /files``. + + File upload uses multipart form data and is therefore not + represented as a param class. + """ + + _fields = ("limit", "page", "scope_id") + + +# =========================================================================== +# Skills +# =========================================================================== + + +class SkillCreateParams(BaseModel): + """Request body for ``POST /skills``. + + ``file_id`` references a file previously uploaded via the Files API. + The server extracts ``name`` and ``description`` from the zip's + ``SKILL.md``; they are not client-settable. + """ + + _fields = ("file_id",) + + +class SkillListParams(BaseModel): + """Query params for ``GET /skills``.""" + + _fields = ("source", "limit", "page") + + +# =========================================================================== +# SkillVersions +# =========================================================================== + + +class SkillVersionCreateParams(BaseModel): + """Request body for ``POST /skills/{skill_id}/versions``.""" + + _fields = ("file_id",) + + +class SkillVersionListParams(BaseModel): + """Query params for ``GET /skills/{skill_id}/versions``.""" + + _fields = ("limit", "page") + + +# =========================================================================== +# Vaults +# =========================================================================== + + +class VaultCreateParams(BaseModel): + """Request body for ``POST /vaults``.""" + + _fields = ("display_name", "metadata") + + +class VaultUpdateParams(BaseModel): + """Request body for ``POST /vaults/{vault_id}``.""" + + _fields = ("display_name", "metadata") + + +class VaultListParams(BaseModel): + """Query params for ``GET /vaults``.""" + + _fields = ("include_archived", "limit", "page") + + def __init__( + self, + *, + include_archived: Optional[bool] = None, + **kwargs: Any, + ) -> None: + if include_archived is not None: + kwargs["include_archived"] = str( + include_archived, + ).lower() + super().__init__(**kwargs) + + +# =========================================================================== +# Credentials +# =========================================================================== + + +class CredentialCreateParams(BaseModel): + """Request body for ``POST /vaults/{vault_id}/credentials``.""" + + _fields = ("auth", "display_name", "metadata") + + def __init__(self, *, auth: Any = None, **kwargs: Any) -> None: + if auth is not None: + if hasattr(auth, "to_dict"): + kwargs["auth"] = auth.to_dict() + elif isinstance(auth, Mapping): + kwargs["auth"] = dict(auth) + else: + kwargs["auth"] = auth + super().__init__(**kwargs) + + +class CredentialUpdateParams(BaseModel): + """Request body for ``POST /vaults/{v}/credentials/{c}``.""" + + _fields = ("auth", "display_name", "metadata") + + def __init__(self, *, auth: Any = None, **kwargs: Any) -> None: + if auth is not None: + if hasattr(auth, "to_dict"): + kwargs["auth"] = auth.to_dict() + elif isinstance(auth, Mapping): + kwargs["auth"] = dict(auth) + else: + kwargs["auth"] = auth + super().__init__(**kwargs) + + +class CredentialListParams(BaseModel): + """Query params for ``GET /vaults/{vault_id}/credentials``.""" + + _fields = ("include_archived", "limit", "page") + + def __init__( + self, + *, + include_archived: Optional[bool] = None, + **kwargs: Any, + ) -> None: + if include_archived is not None: + kwargs["include_archived"] = str( + include_archived, + ).lower() + super().__init__(**kwargs) diff --git a/requirements.txt b/requirements.txt index b3366fa..cf0a7ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ cryptography certifi typer>=0.9.0 rich>=13.0.0 +httpx>=0.25.0 +httpx-sse>=0.4.0 diff --git a/samples/test_agentstudio.py b/samples/test_agentstudio.py new file mode 100644 index 0000000..03d77f4 --- /dev/null +++ b/samples/test_agentstudio.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +import os + +from dashscope.agentstudio import Client, user_message + + +class TestAgentStudio: + """Test cases for AgentStudio synchronous API.""" + + @staticmethod + def test_quickstart(): + """Test end-to-end flow: create env -> agent -> session + -> send message -> stream events -> archive session.""" + api_key = os.environ["DASHSCOPE_API_KEY"] + workspace = os.environ.get("DASHSCOPE_WORKSPACE", "trial") + client = Client(api_key=api_key, workspace=workspace) + + print("==> creating environment") + env = client.environments.create( + name="quickstart-env", + config={ + "type": "cloud", + "networking": {"type": "unrestricted"}, + }, + ) + print(f" environment_id={env.id}") + + print("==> creating agent") + agent = client.agents.create( + name="quickstart-agent", + system_prompt="You are a friendly demo agent. Reply briefly.", + model="qwen-max", + tools=[{"type": "builtin_toolkit", "default_config": {"enabled": False}, + "configs": [{"name": "bash", "enabled": True}]}], + ) + print(f" agent_id={agent.id}") + + print("==> creating session") + session = client.sessions.create( + agent=agent.id, + environment_id=env.id, + title="quickstart", + ) + print(f" session_id={session.id}") + + print("==> sending user message") + client.sessions.events.send( + session.id, + [user_message("Hello! Tell me one fun fact about Hangzhou.")], + ) + + print("==> streaming events") + with client.sessions.events.stream(session.id) as stream: + for text in stream.text_stream: + print(text, end="", flush=True) + print() # newline after streaming + + print("==> archiving session") + client.sessions.archive(session.id) + print("done.") + + +if __name__ == "__main__": + TestAgentStudio.test_quickstart() diff --git a/tests/unit/test_agentstudio_models.py b/tests/unit/test_agentstudio_models.py new file mode 100644 index 0000000..e5a20a8 --- /dev/null +++ b/tests/unit/test_agentstudio_models.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +"""Unit tests for Vault, Credential, DeleteResponse, Agent.system_prompt.""" + +from dashscope.agentstudio.resources._helpers import ( + _coerce_vault, + _coerce_credential, +) +from dashscope.agentstudio.types import ( + Agent, + Credential, + CredentialAuth, + DeleteResponse, + Environment, + File, + Networking, + Vault, +) + + +# --------------------------------------------------------------------------- +# Vault +# --------------------------------------------------------------------------- + + +def test_vault_coerce_parses_all_fields(): + payload = { + "id": "vlt_1", + "type": "vault", + "display_name": "my-vault", + "metadata": {"team": "backend"}, + "created_at": "2026-06-29T10:00:00Z", + "updated_at": "2026-06-29T10:00:00Z", + "archived_at": None, + "request_id": "req_1", + } + v = _coerce_vault(payload) + assert isinstance(v, Vault) + assert v.id == "vlt_1" + assert v.display_name == "my-vault" + assert v.metadata == {"team": "backend"} + assert v.archived_at is None + + +def test_vault_skips_none_fields_in_repr(): + v = Vault(id="vlt_1", display_name="demo") + r = repr(v) + assert "id='vlt_1'" in r + assert "display_name='demo'" in r + assert "archived_at" not in r # None fields omitted + + +# --------------------------------------------------------------------------- +# CredentialAuth — three auth types +# --------------------------------------------------------------------------- + + +def test_credential_auth_environment_variable(): + auth = CredentialAuth( + type="environment_variable", + secret_name="OPENAI_API_KEY", + secret_value="sk-xxx", + networking={"type": "unrestricted"}, + ) + assert auth.type == "environment_variable" + assert auth.secret_name == "OPENAI_API_KEY" + assert isinstance(auth.networking, Networking) + assert auth.networking.type == "unrestricted" + + +def test_credential_auth_static_bearer(): + auth = CredentialAuth( + type="static_bearer", + token="Bearer eyJhbGci...", + ) + assert auth.type == "static_bearer" + assert auth.token == "Bearer eyJhbGci..." + assert auth.networking is None + + +def test_credential_auth_mcp_oauth(): + auth = CredentialAuth( + type="mcp_oauth", + mcp_server_url="https://mcp.example.com/oauth", + ) + assert auth.type == "mcp_oauth" + assert auth.mcp_server_url == "https://mcp.example.com/oauth" + + +def test_credential_auth_secret_value_not_returned_by_server(): + """Server stores secret_value write-only.""" + auth = CredentialAuth( + type="environment_variable", + secret_name="KEY", + # secret_value intentionally absent (server doesn't return it) + ) + assert auth.secret_value is None + + +# --------------------------------------------------------------------------- +# Credential — nested auth parsing +# --------------------------------------------------------------------------- + + +def test_credential_coerce_wraps_auth_dict(): + payload = { + "id": "vcrd_1", + "type": "vault_credential", + "vault_id": "vlt_1", + "display_name": "OpenAI Key", + "auth": { + "type": "environment_variable", + "secret_name": "OPENAI_API_KEY", + "networking": {"type": "restricted"}, + }, + "created_at": "2026-06-29T10:00:00Z", + "updated_at": "2026-06-29T10:00:00Z", + "metadata": {"service": "openai"}, + "archived_at": None, + "request_id": "req_1", + } + c = _coerce_credential(payload) + assert isinstance(c, Credential) + assert c.id == "vcrd_1" + assert c.vault_id == "vlt_1" + assert isinstance(c.auth, CredentialAuth) + assert c.auth.type == "environment_variable" + assert isinstance(c.auth.networking, Networking) + assert c.auth.networking.type == "restricted" + + +def test_credential_coerce_static_bearer(): + payload = { + "id": "vcrd_2", + "type": "vault_credential", + "vault_id": "vlt_1", + "auth": {"type": "static_bearer", "token": "Bearer xyz"}, + "display_name": "API Token", + } + c = _coerce_credential(payload) + assert c.auth.type == "static_bearer" + assert c.auth.token == "Bearer xyz" + + +# --------------------------------------------------------------------------- +# DeleteResponse +# --------------------------------------------------------------------------- + + +def test_delete_response_fields(): + dr = DeleteResponse(id="vlt_1", type="vault_deleted", request_id="req_1") + assert dr.id == "vlt_1" + assert dr.type == "vault_deleted" + assert dr.request_id == "req_1" + + +def test_delete_response_repr_skips_none(): + dr = DeleteResponse(id="x", type="deleted") + assert "request_id" not in repr(dr) + + +# --------------------------------------------------------------------------- +# Agent.system_prompt property alias (gotcha: create param is system_prompt, +# field is system, property bridges them) +# --------------------------------------------------------------------------- + + +def test_agent_system_prompt_is_alias_for_system(): + agent = Agent( + id="agent_1", + name="demo", + model={"id": "qwen-plus"}, + system="你是一个助手。", + ) + assert agent.system == "你是一个助手。" + assert agent.system_prompt == agent.system + + +def test_agent_system_prompt_none_when_system_unset(): + agent = Agent(id="agent_1", name="demo", model={"id": "qwen-plus"}) + assert agent.system is None + assert agent.system_prompt is None + + +# --------------------------------------------------------------------------- +# Environment config nested parsing +# --------------------------------------------------------------------------- + + +def test_environment_config_nested_objects(): + env = Environment( + id="env_1", + name="python-env", + config={ + "type": "cloud", + "networking": {"type": "unrestricted"}, + "packages": {"pip": ["pandas", "numpy"]}, + }, + ) + assert env.config.type == "cloud" + assert isinstance(env.config.networking, Networking) + assert env.config.networking.type == "unrestricted" + assert env.config.packages.pip == ["pandas", "numpy"] + + +# --------------------------------------------------------------------------- +# File model +# --------------------------------------------------------------------------- + + +def test_file_model_fields(): + f = File( + id="file_1", + filename="doc.pdf", + size_bytes=1024, + mime_type="application/pdf", + status="checking", + ) + assert f.id == "file_1" + assert f.filename == "doc.pdf" + assert f.size_bytes == 1024 + assert f.status == "checking" + assert f.downloadable is None # not set diff --git a/tests/unit/test_agentstudio_pagination.py b/tests/unit/test_agentstudio_pagination.py new file mode 100644 index 0000000..e81e189 --- /dev/null +++ b/tests/unit/test_agentstudio_pagination.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# pylint: disable=protected-access +"""Tests for cursor-based pagination.""" + +from __future__ import annotations + +from typing import List + +import pytest + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) + + +def test_build_page(): + page = build_page( + payload={"data": [{"id": "a"}, {"id": "b"}], "next_page": "token_2"}, + item_factory=lambda x: x, + request_id="req_1", + ) + assert len(page.data) == 2 + assert page.next_page == "token_2" + assert page.request_id == "req_1" + + +def test_build_page_empty(): + page = build_page(payload={"data": None}, item_factory=lambda x: x) + assert page.data == [] + + +def test_cursor_page_has_next_and_get_next(): + pages: List[CursorPage[int]] = [ + CursorPage(data=[1, 2], next_page="p2", request_id=None), + CursorPage(data=[3], next_page=None, request_id=None), + ] + call_idx = iter(range(1, 3)) + + def _fetch(_token: str) -> CursorPage[int]: + return pages[next(call_idx)] + + pages[0]._fetch_next = _fetch + assert pages[0].has_next() is True + nxt = pages[0].get_next() + assert nxt is not None + assert nxt.data == [3] + assert nxt.has_next() is False + + +def test_cursor_page_iter_auto_paginates(): + pages: List[CursorPage[int]] = [ + CursorPage(data=[1, 2], next_page="p2", request_id=None), + CursorPage(data=[3], next_page=None, request_id=None), + ] + call_idx = iter(range(1, 3)) + + def _fetch(_token: str) -> CursorPage[int]: + return pages[next(call_idx)] + + pages[0]._fetch_next = _fetch + assert list(pages[0]) == [1, 2, 3] + + +def test_cursor_page_no_next(): + page = CursorPage(data=[1], next_page=None, request_id=None) + assert page.has_next() is False + assert page.get_next() is None + assert list(page) == [1] + + +@pytest.mark.asyncio +async def test_async_cursor_page_iter(): + pages: List[AsyncCursorPage[int]] = [ + AsyncCursorPage(data=[1], next_page="p2", request_id=None), + AsyncCursorPage(data=[2], next_page=None, request_id=None), + ] + call_idx = iter(range(1, 3)) + + async def _fetch(_token: str) -> AsyncCursorPage[int]: + return pages[next(call_idx)] + + pages[0]._fetch_next = _fetch + result = [item async for item in pages[0]] + assert result == [1, 2] + + +@pytest.mark.asyncio +async def test_async_cursor_page_no_next(): + page = AsyncCursorPage(data=[1], next_page=None, request_id=None) + assert page.has_next() is False + assert await page.get_next() is None + result = [item async for item in page] + assert result == [1] diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/unit/test_agentstudio_protocol.py new file mode 100644 index 0000000..a028afc --- /dev/null +++ b/tests/unit/test_agentstudio_protocol.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- +"""Protocol-alignment tests: error / snake_case / data flatten. + +These three contracts were agreed on with the backend team: + +1. Error envelope uses ``error.code`` / ``error.message`` (the + documented shape). Legacy ``error_code`` / ``error_message`` is + still tolerated for compatibility. +2. Wire format is snake_case throughout — both request bodies emitted + by the SDK and response bodies returned by the server. The only + defensive translation is ``requestId`` → ``request_id`` because + some legacy gateways still emit camelCase for that single field. +3. Responses come back as flat bare JSON; the SDK exposes the resource + at the top level via :func:`unwrap`. +""" + +import json + +import pytest + +from dashscope.agentstudio import exceptions +from dashscope.agentstudio.transport import is_error_payload, unwrap +from dashscope.agentstudio.types import ( + user_custom_tool_result, + user_define_outcome, + user_interrupt, + user_message, + user_tool_confirmation, +) + +# --------------------------------------------------------------------------- +# 1. error.{code, message} +# --------------------------------------------------------------------------- + + +def test_error_uses_nested_code_and_message(): + body = { + "type": "error", + "error": {"code": "invalid_request_error", "message": "bad arg"}, + "request_id": "req_001", + } + err = exceptions.from_response(status_code=400, body=body) + assert isinstance(err, exceptions.InvalidRequestError) + assert err.code == "invalid_request_error" + assert err.message == "bad arg" + assert err.request_id == "req_001" + + +def test_error_legacy_underscored_fields_still_parsed(): + body = { + "type": "error", + "error": {"error_code": "rate_limit_error", "error_message": "slow"}, + } + err = exceptions.from_response(status_code=429, body=body) + assert isinstance(err, exceptions.RateLimitError) + assert err.code == "rate_limit_error" + + +def test_is_error_payload_detects_both_shapes(): + assert is_error_payload( + {"type": "error", "error": {"code": "x", "message": "y"}}, + ) + assert is_error_payload( + {"type": "error", "error": {"error_code": "x", "error_message": "y"}}, + ) + + +# --------------------------------------------------------------------------- +# 2. snake_case wire format +# --------------------------------------------------------------------------- + + +def test_client_event_keys_are_snake_case(): + """Every key the SDK emits in user.* events must be snake_case.""" + samples = [ + user_message( + "hi", + session_thread_id="th_1", + metadata={"k": "v"}, + ), + user_tool_confirmation( + tool_use_id="t_1", + result="allow", + ), + user_tool_confirmation( + tool_use_id="t_1", + result="deny", + deny_message="nope", + ), + user_custom_tool_result( + custom_tool_use_id="ctu_1", + content="ok", + is_error=False, + ), + user_define_outcome( + description="desc", + rubric="r", + max_iterations=3, + ), + ] + + def _walk_keys(obj): + if isinstance(obj, dict): + for k, v in obj.items(): + assert "_" in k or k.islower(), f"non-snake_case key: {k}" + # No camel-case (i.e. no internal uppercase letters) + assert not any(c.isupper() for c in k), f"camelCase leak: {k}" + _walk_keys(v) + elif isinstance(obj, list): + for it in obj: + _walk_keys(it) + + for evt in samples: + # Re-serialize through json to make sure no funny key types slipped in + _walk_keys(json.loads(json.dumps(evt))) + + +def test_unwrap_keeps_snake_case_payloads_unchanged(): + payload = { + "id": "agt_1", + "name": "demo", + "system_prompt": "you are helpful", + "created_at": "2026-06-16T10:00:00Z", + "request_id": "req_1", + } + data, rid = unwrap(payload) + assert data == payload + assert rid == "req_1" + + +def test_unwrap_only_camelcase_translation_is_request_id(): + """Other camelCase keys are left as-is; SDK trusts the backend + to send snake_case per the agreed wire contract. + """ + payload = {"id": "agt_1", "fooBar": 1, "requestId": "req_x"} + data, rid = unwrap(payload) + assert data["fooBar"] == 1 # not auto-converted + assert "request_id" in data and "requestId" not in data + assert rid == "req_x" + + +# --------------------------------------------------------------------------- +# 3. flat response +# --------------------------------------------------------------------------- + + +def test_flat_response_unchanged(): + payload = {"id": "agt_1", "request_id": "req_1"} + data, rid = unwrap(payload) + assert data == {"id": "agt_1", "request_id": "req_1"} + assert rid == "req_1" + + +# --------------------------------------------------------------------------- +# Tests from test_agentstudio_client_events.py +# --------------------------------------------------------------------------- + + +def test_user_message_string_wraps_to_text_block(): + evt = user_message("hello") + assert evt["type"] == "message" + assert evt["role"] == "user" + assert evt["content"] == [{"type": "text", "text": "hello"}] + + +def test_user_message_list_passthrough(): + blocks = [{"type": "image", "source": "..."}] + evt = user_message(blocks, session_thread_id="th_1", metadata={"k": "v"}) + assert evt["type"] == "message" + assert evt["role"] == "user" + assert evt["content"] == blocks + assert evt["session_thread_id"] == "th_1" + assert evt["metadata"] == {"k": "v"} + + +def test_user_interrupt(): + evt = user_interrupt() + assert evt == {"role": "user", "type": "interrupt"} + + +def test_user_tool_confirmation_validates_result(): + with pytest.raises(ValueError): + user_tool_confirmation(tool_use_id="t_1", result="MAYBE") + deny = user_tool_confirmation( + tool_use_id="t_1", + result="deny", + deny_message="nope", + ) + assert deny["role"] == "user" + assert deny["type"] == "tool_confirmation" + data_block = deny["content"][0] + assert data_block["type"] == "data" + assert data_block["data"]["call_id"] == "t_1" + assert data_block["data"]["result"] == "deny" + assert data_block["data"]["deny_message"] == "nope" + + +def test_user_custom_tool_result_string_to_text(): + evt = user_custom_tool_result(custom_tool_use_id="ctu_1", content="42") + assert evt["role"] == "tool" + assert evt["type"] == "function_call_output" + data_block = evt["content"][0] + assert data_block["type"] == "data" + assert data_block["data"]["call_id"] == "ctu_1" + assert data_block["data"]["output"] == "42" + assert evt["is_error"] is False + + +def test_user_custom_tool_result_dict_to_data_block(): + evt = user_custom_tool_result( + custom_tool_use_id="ctu_1", + content={"x": 1}, + is_error=True, + ) + assert evt["role"] == "tool" + assert evt["type"] == "function_call_output" + data_block = evt["content"][0] + assert data_block["type"] == "data" + assert data_block["data"]["call_id"] == "ctu_1" + assert data_block["data"]["output"] == {"x": 1} + assert evt["is_error"] is True + + +def test_user_define_outcome(): + evt = user_define_outcome( + description="task A", + rubric="must be JSON", + max_iterations=3, + ) + assert evt["role"] == "user" + assert evt["type"] == "define_outcome" + data_block = evt["content"][0] + assert data_block["type"] == "data" + assert data_block["data"]["description"] == "task A" + assert data_block["data"]["rubric"] == "must be JSON" + assert data_block["data"]["max_iterations"] == 3 + + +# --------------------------------------------------------------------------- +# Tests for Agent/Session type field mapping +# --------------------------------------------------------------------------- + + +def test_agent_system_field_and_property(): + """Server returns ``system`` field; SDK exposes it both as + ``agent.system`` and the convenience ``agent.system_prompt`` property. + """ + from dashscope.agentstudio.types import Agent + + agent = Agent(id="agt_1", system="You are helpful.", name="Test") + assert agent.system == "You are helpful." + assert agent.system_prompt == "You are helpful." + + +def test_session_stats_and_usage_fields(): + """Session now parses ``stats`` and ``usage`` from server response.""" + from dashscope.agentstudio.types import Session + + stats = {"active_seconds": 42, "duration_seconds": 5} + usage = {"input_tokens": 100, "output_tokens": 200} + s = Session(id="sesn_1", status="idle", stats=stats, usage=usage) + assert s.stats.active_seconds == 42 + assert s.stats.duration_seconds == 5 + assert s.usage.input_tokens == 100 + assert s.usage.output_tokens == 200 + + +def test_from_response_spring_default(): + """Spring Boot default error page is coerced to the right error type.""" + body = { + "timestamp": "...", + "status": 404, + "error": "Not Found", + "path": "/api/v1/agentstudio/agents", + } + err = exceptions.from_response(status_code=404, body=body) + assert isinstance(err, exceptions.NotFoundError) + + +# --------------------------------------------------------------------------- +# 4. agents.update version contract (no auto-retrieve) +# --------------------------------------------------------------------------- + + +class _RecordingTransport: + """Minimal transport that records requests and returns a canned agent.""" + + def __init__(self): + self.calls = [] + + def request(self, method, path, **kwargs): + self.calls.append({"method": method, "path": path, **kwargs}) + from dashscope.agentstudio.transport import APIResponse + + return APIResponse( + data={"id": "agent_1", "version": 3, "name": "demo"}, + request_id="req_1", + ) + + +def _client_with_recording_transport(): + from dashscope.agentstudio import Client + + c = Client(api_key="test-key", base_url="http://test") + c.transport = _RecordingTransport() + return c + + +def test_agents_update_requires_version_kwarg(): + """version is required — omitting it is a TypeError.""" + client = _client_with_recording_transport() + with pytest.raises(TypeError): + # pylint: disable=missing-kwoa + client.agents.update( # type: ignore[call-arg] + "agent_1", + name="new-name", + ) + + +def test_agents_update_with_version_sends_body(): + """update() sends POST /agents/{id} with version.""" + client = _client_with_recording_transport() + client.agents.update( + "agent_1", + version=3, + name="new-name", + ) + assert len(client.transport.calls) == 1 + call = client.transport.calls[0] + assert call["method"] == "POST" + assert call["path"] == "/agents/agent_1" + body = call["json"] + assert body["version"] == 3 + assert body["name"] == "new-name" + + +def test_agents_update_does_not_auto_retrieve(): + """SDK must NOT call retrieve() internally.""" + client = _client_with_recording_transport() + client.agents.update( + "agent_1", + version=3, + name="new-name", + ) + assert len(client.transport.calls) == 1 + assert client.transport.calls[0]["method"] == "POST" diff --git a/tests/unit/test_agentstudio_skills.py b/tests/unit/test_agentstudio_skills.py new file mode 100644 index 0000000..0fda724 --- /dev/null +++ b/tests/unit/test_agentstudio_skills.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""Skill resource shape & auto-upload compatibility. + +Tests create a client with a mocked transport and patch Files.upload +on the client's files instance since the SDK uses client instance mode. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import pytest + +from dashscope.agentstudio import Client +from dashscope.agentstudio.transport import APIResponse +from dashscope.agentstudio.resources.skills import SkillVersions + + +class _Tx: + def __init__(self): + self.calls: List[Dict[str, Any]] = [] + + def request(self, method, path, **kwargs): + self.calls.append({"method": method, "path": path, **kwargs}) + if "/versions" in path: + return APIResponse( + data={"id": "skv_1", "skill_id": "skl_1", "version": 1}, + request_id="req_2", + ) + if path == "/skills": + return APIResponse( + data={"id": "skl_1", "name": "demo"}, + request_id="req_1", + ) + return APIResponse(data={}, request_id=None) + + +@pytest.fixture(name="client") +def _client_fixture(): + """Create a client with a recording transport.""" + c = Client(api_key="test-key", base_url="http://test") + c.transport = _Tx() + return c + + +@pytest.fixture(name="fake_upload") +def _fake_upload_fixture(client, monkeypatch): + """Patch Files.upload on the client's files instance to record calls.""" + calls: List[Dict[str, Any]] = [] + file_id_holder = {"id": "file_auto"} + + def _upload(_self_files, file, *, mime_type=None, **_kwargs): + calls.append({"file": file, "mime_type": mime_type}) + + class _F: + id = file_id_holder["id"] + + return _F() + + monkeypatch.setattr(type(client.files), "upload", _upload) + return calls, file_id_holder + + +def test_skill_create_with_file_id_does_not_call_upload(client, fake_upload): + upload_calls, _ = fake_upload + client.skills.create(file_id="file_existing") + + assert upload_calls == [] + body = client.transport.calls[0]["json"] + assert body == {"file_id": "file_existing"} + + +def test_skill_create_with_file_path_auto_uploads_first(client, fake_upload): + upload_calls, holder = fake_upload + holder["id"] = "file_uploaded" + + client.skills.create(file="/tmp/skill.zip") + + assert len(upload_calls) == 1 + assert upload_calls[0]["file"] == "/tmp/skill.zip" + assert upload_calls[0]["mime_type"] == "application/zip" + body = client.transport.calls[0]["json"] + assert body["file_id"] == "file_uploaded" + + +def test_skill_create_rejects_both_file_and_file_id(client): + with pytest.raises(TypeError): + client.skills.create(file_id="x", file="/tmp/y.zip") + + +def test_skill_create_rejects_neither_file_nor_file_id(client): + with pytest.raises(TypeError): + client.skills.create() + + +def test_skill_versions_create_auto_upload(client, fake_upload): + upload_calls, holder = fake_upload + holder["id"] = "file_v2" + + sv = SkillVersions(client) + sv.create("skl_1", file="/tmp/v2.zip") + + assert len(upload_calls) == 1 + assert upload_calls[0]["file"] == "/tmp/v2.zip" + body = client.transport.calls[0]["json"] + assert body == {"file_id": "file_v2"} + + +def test_skill_versions_create_with_file_id_no_upload(client, fake_upload): + upload_calls, _ = fake_upload + + sv = SkillVersions(client) + sv.create("skl_1", file_id="file_existing") + + assert upload_calls == [] + body = client.transport.calls[0]["json"] + assert body == {"file_id": "file_existing"} + + +def test_skill_versions_create_explicit_mime_type_overrides( + client, + fake_upload, +): + upload_calls, _ = fake_upload + + sv = SkillVersions(client) + sv.create("skl_1", file="/tmp/v.tar.gz", mime_type="application/gzip") + assert upload_calls[0]["mime_type"] == "application/gzip"