diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md index 3f4e227..75762b5 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md @@ -13,7 +13,7 @@ description: Quick reference for Agent APIs (agentstudio-hosted Agent/Applicatio ### agentstudio (`from dashscope.agentstudio import Client, AsyncClient`) - `Client(api_key=, workspace=, region=None, base_url=None, uid=None, timeout=None, max_retries=2)`; if base_url is not given, `workspace` (or env var `DASHSCOPE_WORKSPACE`) is required; base_url can also be set via `DASHSCOPE_AGENTSTUDIO_URL`/`AGENTSTUDIO_URL`; default region `cn-beijing`, timeout 600s. - Resource entry points: `client.agents / .sessions / .environments / .files / .skills / .vaults / .deployments / .deployment_runs`. -- `agents.create(*, name, model, description=None, system_prompt=None, tools=None, mcp_servers=None, skills=None, metadata=None)` → `Agent`; `retrieve(agent_id, *, version=None)` (alias `get`); `update(agent_id, *, version, ...)` (version must equal the server's current version, otherwise HTTP 409); `archive(agent_id)`; `list(*, limit, page, include_archived)` → `CursorPage`; `list_versions(agent_id)`. +- `agents.create(*, name, model, description=None, system_prompt=None, tools=None, mcp_servers=None, skills=None, multiagent=None, metadata=None)` → `Agent`; `model` is a `ModelConfig(id=…, temperature=…, max_tokens=…, enable_thinking=…, thinking_budget=…)` (bare id string = `ModelConfig(id=…)`); `retrieve(agent_id, *, version=None)` (alias `get`); `update(agent_id, *, version, name, model=None, ...)` — version must equal the current version (HTTP 409), `name` is required (`AGENT_005`), and a sent `model` replaces the whole config (keep the rest via `agent.model.copy_with(...)`); `archive(agent_id)`; `list(*, limit, page, include_archived)` → `CursorPage`; `list_versions(agent_id)`. - `sessions.create(*, agent, environment_id=None, title=None, resources=None, metadata=None)` → `Session`; also `retrieve/update(title, metadata)/list(*, limit, page, agent_id, statuses, created_at_gt/gte/lt/lte)/archive/delete`. - `environments.create(*, name, config, description=None, scope="organization", metadata=None)`; `files.upload(file, *, mime_type=None, progress=None)` (file accepts a path/binary stream/(name, fileobj)); `skills.create(*, file_id=None, file=None, mime_type=None)`, `skills.versions.create(skill_id, ...)`. diff --git a/dashscope/agentstudio/__init__.py b/dashscope/agentstudio/__init__.py index edd5818..cc15a94 100644 --- a/dashscope/agentstudio/__init__.py +++ b/dashscope/agentstudio/__init__.py @@ -55,6 +55,7 @@ MemoryStore, MemoryVersion, Message, + ModelConfig, PermissionPolicy, SecurityAlert, SecurityAlertList, @@ -124,6 +125,7 @@ "MemoryStore", "MemoryVersion", "Message", + "ModelConfig", "PermissionPolicy", "SecurityAlert", "SecurityAlertList", diff --git a/dashscope/agentstudio/client.py b/dashscope/agentstudio/client.py index be840af..0b47e82 100644 --- a/dashscope/agentstudio/client.py +++ b/dashscope/agentstudio/client.py @@ -100,10 +100,18 @@ class Client: Usage:: - from dashscope.agentstudio import Client + from dashscope.agentstudio import Client, ModelConfig client = Client(api_key="sk-xxx", workspace="my-ws") - agent = client.agents.create(name="demo", model="qwen-max") + agent = client.agents.create( + name="demo", + model=ModelConfig( + id="qwen-max", + temperature=0.7, + enable_thinking=True, + thinking_budget=2048, + ), + ) session = client.sessions.create(agent=agent.id) with client.sessions.events.stream(session.id) as stream: for event in stream: @@ -173,11 +181,12 @@ class AsyncClient: Usage:: - from dashscope.agentstudio import AsyncClient + from dashscope.agentstudio import AsyncClient, ModelConfig client = AsyncClient(api_key="sk-xxx", workspace="my-ws") agent = await client.agents.create( - name="demo", model="qwen-max", + name="demo", + model=ModelConfig(id="qwen-max", max_tokens=4096), ) session = await client.sessions.create(agent=agent.id) async with client.sessions.events.stream(session.id) as stream: diff --git a/dashscope/agentstudio/resources/agents.py b/dashscope/agentstudio/resources/agents.py index 91bf155..5371281 100644 --- a/dashscope/agentstudio/resources/agents.py +++ b/dashscope/agentstudio/resources/agents.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Any, Mapping, Optional, Sequence +from typing import Any, Mapping, Optional, Sequence, Union from dashscope.agentstudio.pagination import ( AsyncCursorPage, @@ -15,7 +15,7 @@ _coerce_agent, _coerce_agent_version, ) -from dashscope.agentstudio.types import Agent, AgentVersion +from dashscope.agentstudio.types import Agent, AgentVersion, ModelConfig from dashscope.agentstudio.types.params import ( AgentCreateParams, AgentListParams, @@ -36,7 +36,7 @@ def create( self, *, name: str, - model: str, + model: Union[str, ModelConfig], description: Optional[str] = None, system_prompt: Optional[str] = None, tools: Optional[Sequence[Mapping[str, Any]]] = None, @@ -85,8 +85,8 @@ def update( *, version: int, name: Optional[str] = None, + model: Optional[Union[str, ModelConfig]] = 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, @@ -99,12 +99,13 @@ def update( ``version`` must equal the server's current version; the server rejects mismatches with HTTP 409. Retrieve the agent first to obtain the current version. + ``name`` is required too (``AGENT_005`` without it). """ body = AgentUpdateParams( version=version, name=name, - description=description, model=model, + description=description, system_prompt=system_prompt, tools=tools, mcp_servers=mcp_servers, @@ -189,7 +190,7 @@ async def create( self, *, name: str, - model: str, + model: Union[str, ModelConfig], description: Optional[str] = None, system_prompt: Optional[str] = None, tools: Optional[Sequence[Mapping[str, Any]]] = None, @@ -242,8 +243,8 @@ async def update( *, version: int, name: Optional[str] = None, + model: Optional[Union[str, ModelConfig]] = 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, @@ -256,12 +257,13 @@ async def update( ``version`` must equal the server's current version; the server rejects mismatches with HTTP 409. Retrieve the agent first to obtain the current version. + ``name`` is required too (``AGENT_005`` without it). """ body = AgentUpdateParams( version=version, name=name, - description=description, model=model, + description=description, system_prompt=system_prompt, tools=tools, mcp_servers=mcp_servers, diff --git a/dashscope/agentstudio/resources/session_events.py b/dashscope/agentstudio/resources/session_events.py index 617acaf..aa27259 100644 --- a/dashscope/agentstudio/resources/session_events.py +++ b/dashscope/agentstudio/resources/session_events.py @@ -45,6 +45,15 @@ ) +def _terminal_status(event: Any) -> bool: + """True when a ``session_status`` event reports that the run is over.""" + if getattr(event, "type", None) != SSEEventType.SESSION_STATUS: + return False + block = event.content[0] if event.content else None + data = getattr(block, "data", None) or {} + return data.get("session_status") in _TERMINAL_STATUSES + + class SessionEvents: """Session event send / list / stream.""" @@ -125,9 +134,13 @@ def stream( ) -> "_TypedEventStream": """Open the SSE stream and return an iterator of typed events. - ``event_deltas`` opts into incremental text streaming for the given - event types (``"message"`` and/or ``"reasoning"``; aliases - ``"agent.message"`` / ``"agent.thinking"``). + ``event_deltas`` opts into incremental streaming for the given event + types (``"message"`` and/or ``"reasoning"``; aliases ``"agent.message"`` + / ``"agent.thinking"``); anything else the server rejects. The stream is + read once, so ``text_deltas`` can be taken from it only; a second pass + raises rather than returning nothing. A chunk stream is not a reliable + source for final text: a reconnect replays only persisted events, so use + ``text_stream``. """ params: Optional[Dict[str, Any]] = None if event_deltas: @@ -146,7 +159,13 @@ def stream( class _TypedEventStream: - """Wrap :class:`EventStream` so iterating yields :class:`ServerEvent`.""" + """Wrap :class:`EventStream` so iterating yields :class:`ServerEvent`. + + A stream is read once: a second pass would silently yield nothing, so it + raises instead. Call ``sessions.events.stream()`` again to read the events. + """ + + _passed = False def __init__(self, stream: EventStream) -> None: self._stream = stream @@ -179,6 +198,15 @@ def __exit__(self, *exc: Any) -> None: self.close() def __iter__(self) -> Iterator[ServerEvent]: + if self._passed: + raise RuntimeError( + "this stream is single-pass and was already consumed; call " + "sessions.events.stream() again to read it again", + ) + self._passed = True + return self._iter() + + def _iter(self) -> Iterator[ServerEvent]: for payload in self._stream: yield _coerce_event(payload) @@ -196,30 +224,27 @@ def text_stream(self): 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 _TERMINAL_STATUSES: - return + elif _terminal_status(event): + return @property def text_deltas(self): """Iterate over incremental text chunks from ``event_delta`` frames. Requires the stream to be opened with ``event_deltas``; otherwise - yields nothing (use :attr:`text_stream` for terminal full text). + yields nothing (use :attr:`text_stream` for terminal full text, which is + also what a reconnect can rebuild — chunks are not replayed). """ + return self._text_deltas() + + def _text_deltas(self): for event in self: - etype = getattr(event, "type", None) - if etype == "event_delta": + if getattr(event, "type", None) == "event_delta": text = event.delta_text if text: yield text - elif etype == SSEEventType.SESSION_STATUS: - block = event.content[0] if event.content else None - d = getattr(block, "data", None) or {} - if d.get("session_status") in _TERMINAL_STATUSES: - return + elif _terminal_status(event): + return def close(self) -> None: self._stream.close() @@ -331,7 +356,15 @@ async def __aenter__(self) -> "_AioTypedEventStream": async def __aexit__(self, *exc: Any) -> None: await self.aclose() + _passed = False + def __aiter__(self) -> AsyncIterator[ServerEvent]: + if self._passed: + raise RuntimeError( + "this stream is single-pass and was already consumed; call " + "sessions.events.stream() again to read it again", + ) + self._passed = True return self._aiter() async def _aiter(self) -> AsyncIterator[ServerEvent]: @@ -358,20 +391,17 @@ async def _text_stream(self): @property def text_deltas(self): + """Async incremental text chunks from ``event_delta`` frames.""" return self._text_deltas() async def _text_deltas(self): async for event in self: - etype = getattr(event, "type", None) - if etype == "event_delta": + if getattr(event, "type", None) == "event_delta": text = event.delta_text if text: yield text - elif etype == SSEEventType.SESSION_STATUS: - block = event.content[0] if event.content else None - d = getattr(block, "data", None) or {} - if d.get("session_status") in _TERMINAL_STATUSES: - return + elif _terminal_status(event): + return async def aclose(self) -> None: await self._stream.aclose() diff --git a/dashscope/agentstudio/types/__init__.py b/dashscope/agentstudio/types/__init__.py index 9cd3747..2bb98d1 100644 --- a/dashscope/agentstudio/types/__init__.py +++ b/dashscope/agentstudio/types/__init__.py @@ -30,6 +30,7 @@ AgentVersion, MultiAgentConfig, MultiAgentRosterEntry, + ModelConfig, Credential, CredentialAuth, Memory, diff --git a/dashscope/agentstudio/types/models.py b/dashscope/agentstudio/types/models.py index f56ef69..dac2178 100644 --- a/dashscope/agentstudio/types/models.py +++ b/dashscope/agentstudio/types/models.py @@ -446,6 +446,44 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) +class ModelConfig(BaseModel): + """Model configuration of an agent (the ``model`` field). + + Wire names as-is: ``id`` required, inference params optional (unset ones + are omitted so the server default applies), ranges validated server-side. + ``name`` is console-only: the public API neither accepts nor returns it. + Read or write the wire dict with :meth:`to_dict`. + """ + + _fields = ( + "id", + "name", + "temperature", + "max_tokens", + "enable_thinking", + "thinking_budget", + ) + + def copy_with(self, **changes: Any) -> "ModelConfig": + """Copy with ``changes`` applied (``None`` drops a field). + + Update replaces the whole config, so this changes one knob without + clearing the rest. A name outside :attr:`_fields` has to be carried + by this config already, so a forward-compat field can be retuned and + a typo still cannot. + """ + unknown = set(changes) - set(self._fields) - set(self.to_dict()) + if unknown: + raise ValueError( + "unknown model config field(s): " + ", ".join(sorted(unknown)), + ) + payload = self.to_dict() + payload.update(changes) + return ModelConfig( + **{k: v for k, v in payload.items() if v is not None}, + ) + + class Agent(BaseModel): _fields = ( "id", @@ -468,6 +506,9 @@ class Agent(BaseModel): ) def __init__(self, **kwargs: Any) -> None: + model = kwargs.get("model") + if isinstance(model, Mapping): + kwargs["model"] = ModelConfig(**dict(model)) multiagent = kwargs.get("multiagent") if isinstance(multiagent, Mapping): kwargs["multiagent"] = MultiAgentConfig(**dict(multiagent)) @@ -478,6 +519,16 @@ def system_prompt(self) -> Optional[str]: """Alias: server field is ``system``, kept for SDK user convenience.""" return self.system + @property + def model_id(self) -> Optional[str]: + """Convenience: the model id carried by ``model`` (None if unset).""" + model = self.model + if isinstance(model, ModelConfig): + return model.id + if isinstance(model, Mapping): + return model.get("id") + return model if isinstance(model, str) else None + class AgentVersion(BaseModel): _fields = ( diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py index d85faa6..6742e94 100644 --- a/dashscope/agentstudio/types/params.py +++ b/dashscope/agentstudio/types/params.py @@ -16,9 +16,9 @@ from __future__ import annotations -from typing import Any, Mapping, Optional, Sequence +from typing import Any, Dict, Mapping, Optional, Sequence, Union -from dashscope.agentstudio.types.models import BaseModel +from dashscope.agentstudio.types.models import BaseModel, ModelConfig _NOT_GIVEN = object() @@ -31,6 +31,24 @@ def _to_mapping(value: Any) -> Any: return value +def _resolve_model( + model: Optional[Union[str, ModelConfig]], +) -> Dict[str, Any]: + """Wire ``model`` from a ModelConfig or a bare model id; id required.""" + if model is None: + raise ValueError("model is required") + if isinstance(model, str): + model = ModelConfig(id=model) + if not isinstance(model, ModelConfig): + raise TypeError( + f"model must be a ModelConfig, got {type(model).__name__} " + "(use ModelConfig(**raw))", + ) + if not model.id: + raise ValueError("model.id is required") + return model.to_dict() + + # =========================================================================== # Agents # =========================================================================== @@ -39,9 +57,10 @@ def _to_mapping(value: Any) -> Any: 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``. + Maps to :meth:`Agents.create`. The SDK method exposes ``model`` as a + :class:`ModelConfig` or a bare model id, and ``system_prompt`` as a + keyword arg; the wire format uses ``model: {"id": "…", …}`` and + ``system``. """ _fields = ( @@ -60,7 +79,7 @@ def __init__( self, *, name: str, - model: str, + model: Union[str, ModelConfig], description: Optional[str] = None, system_prompt: Optional[str] = None, tools: Optional[Sequence[Mapping[str, Any]]] = None, @@ -71,7 +90,7 @@ def __init__( ) -> None: super().__init__( name=name, - model={"id": model}, + model=_resolve_model(model), description=description, system=system_prompt, tools=([dict(t) for t in tools] if tools is not None else None), @@ -109,7 +128,7 @@ def __init__( *, version: int, name: Optional[str] = None, - model: Optional[str] = None, + model: Optional[Union[str, ModelConfig]] = None, description: Optional[str] = None, system_prompt: Optional[str] = None, tools: Optional[Sequence[Mapping[str, Any]]] = None, @@ -121,7 +140,7 @@ def __init__( super().__init__( version=version, name=name, - model=({"id": model} if model is not None else None), + model=(_resolve_model(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), diff --git a/examples/03_agent_recipes/agentstudio.py b/examples/03_agent_recipes/agentstudio.py index 8f38150..682855c 100644 --- a/examples/03_agent_recipes/agentstudio.py +++ b/examples/03_agent_recipes/agentstudio.py @@ -22,7 +22,7 @@ import os import dashscope -from dashscope.agentstudio import user_message +from dashscope.agentstudio import ModelConfig, user_message def make_client(): @@ -48,10 +48,16 @@ def main() -> None: print(f" environment_id={env.id}") print("==> creating agent") + # model is a ModelConfig (a bare id string is the ModelConfig(id=...) + # shorthand); unset params keep the server defaults. agent = studio.agents.create( name="quickstart-agent", system_prompt="You are a friendly demo agent. Reply briefly.", - model="qwen3.8-max", + model=ModelConfig( + id="qwen3.8-max", + temperature=0.7, + max_tokens=4096, + ), tools=[ { "type": "builtin_toolkit", @@ -60,7 +66,7 @@ def main() -> None: }, ], ) - print(f" agent_id={agent.id}") + print(f" agent_id={agent.id} model={agent.model_id}") print("==> creating session") session = studio.sessions.create( diff --git a/tests/unit/test_agentstudio_models.py b/tests/unit/test_agentstudio_models.py index e5a20a8..3fecdd8 100644 --- a/tests/unit/test_agentstudio_models.py +++ b/tests/unit/test_agentstudio_models.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- """Unit tests for Vault, Credential, DeleteResponse, Agent.system_prompt.""" +import pytest + from dashscope.agentstudio.resources._helpers import ( _coerce_vault, _coerce_credential, @@ -12,6 +14,7 @@ DeleteResponse, Environment, File, + ModelConfig, Networking, Vault, ) @@ -181,6 +184,106 @@ def test_agent_system_prompt_none_when_system_unset(): assert agent.system_prompt is None +# --------------------------------------------------------------------------- +# Agent model config (model.id + inference params) +# --------------------------------------------------------------------------- + + +_FULL_MODEL = { + "id": "qwen3-max", + "name": "Qwen3 Max", + "temperature": 0.7, + "max_tokens": 8192, + "enable_thinking": True, + "thinking_budget": 2048, +} + + +def test_agent_model_hydrates_into_model_config(): + """The ``model`` object is typed, so inference params are attribute-readable.""" + agent = Agent(id="agent_1", model=dict(_FULL_MODEL)) + assert isinstance(agent.model, ModelConfig) + assert agent.model.id == "qwen3-max" + assert agent.model.name == "Qwen3 Max" + assert agent.model.temperature == 0.7 + assert agent.model.max_tokens == 8192 + assert agent.model.enable_thinking is True + assert agent.model.thinking_budget == 2048 + assert agent.model_id == "qwen3-max" + + +def test_agent_model_reads_typed_and_serializes_via_to_dict(): + """Attributes carry wire names; ``to_dict()`` is the dict view.""" + agent = Agent(id="agent_1", model=dict(_FULL_MODEL)) + assert agent.model.to_dict() == _FULL_MODEL + assert agent.model.max_tokens == 8192 + with pytest.raises(TypeError): + _ = agent.model["id"] + + +def test_agent_model_omits_unset_params_and_keeps_unknown_fields(): + """Unset fields omitted; unknown fields preserved.""" + agent = Agent(id="agent_1", model={"id": "qwen3-max", "vendor": "qwen"}) + assert agent.model.to_dict() == {"id": "qwen3-max", "vendor": "qwen"} + assert agent.model.temperature is None + assert agent.model_id == "qwen3-max" + + +def test_agent_model_absent_stays_none(): + agent = Agent(id="agent_1", name="demo") + assert agent.model is None + assert agent.model_id is None + + +def test_agent_model_accepts_model_config_on_construction(): + """A ModelConfig round-trips back into an Agent.""" + config = ModelConfig(**_FULL_MODEL) + agent = Agent(id="agent_1", model=config) + assert agent.model is config + assert Agent(**agent.to_dict()).model.temperature == 0.7 + + +def test_model_config_copy_with_keeps_the_rest(): + """copy_with() is the read-modify-write helper for update.""" + config = ModelConfig(**_FULL_MODEL) + tuned = config.copy_with(temperature=0.1) + assert tuned.temperature == 0.1 + assert tuned.to_dict() == dict(_FULL_MODEL, temperature=0.1) + assert config.temperature == 0.7 # original untouched + assert isinstance(tuned, ModelConfig) + + +def test_model_config_copy_with_can_drop_and_reject_typos(): + config = ModelConfig(**_FULL_MODEL) + # None drops the field: "let the server apply its default" + assert ( + "thinking_budget" + not in config.copy_with( + thinking_budget=None, + ).to_dict() + ) + # typos fail loudly instead of being sent as unknown wire fields + with pytest.raises(ValueError, match="unknown model config field"): + config.copy_with(temp=0.5) + + +def test_model_config_unknown_fields_survive_copy_with(): + """Forward-compat fields survive copy_with and can be retuned.""" + config = ModelConfig(id="qwen3-max", vendor="qwen") + assert config.copy_with(temperature=0.5).to_dict() == { + "id": "qwen3-max", + "vendor": "qwen", + "temperature": 0.5, + } + assert config.copy_with(vendor="other").to_dict() == { + "id": "qwen3-max", + "vendor": "other", + } + # still not a way to invent wire fields: neither known nor carried + with pytest.raises(ValueError, match="unknown model config field"): + config.copy_with(vendorx="nope") + + # --------------------------------------------------------------------------- # Environment config nested parsing # --------------------------------------------------------------------------- diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/unit/test_agentstudio_protocol.py index 3633591..6cf6c0e 100644 --- a/tests/unit/test_agentstudio_protocol.py +++ b/tests/unit/test_agentstudio_protocol.py @@ -448,6 +448,314 @@ def test_agent_update_body_includes_multiagent(): } +# --------------------------------------------------------------------------- +# Agent model config — one object on create/update +# --------------------------------------------------------------------------- + + +def test_agent_create_body_model_id_shorthand(): + """Bare model id == ModelConfig(id=...) shorthand.""" + from dashscope.agentstudio.types.params import AgentCreateParams + + body = AgentCreateParams(name="a", model="qwen3-max").to_dict() + assert body["model"] == {"id": "qwen3-max"} + + +def test_agent_create_body_model_config_object(): + """The model config is one object carrying id + inference params.""" + from dashscope.agentstudio.types import ModelConfig + from dashscope.agentstudio.types.params import AgentCreateParams + + body = AgentCreateParams( + name="a", + model=ModelConfig( + id="qwen3-max", + temperature=0.7, + max_tokens=8192, + enable_thinking=True, + thinking_budget=2048, + ), + ).to_dict() + assert body["model"] == { + "id": "qwen3-max", + "temperature": 0.7, + "max_tokens": 8192, + "enable_thinking": True, + "thinking_budget": 2048, + } + # emitted in the documented field order, so recorded bodies stay stable + assert list(body["model"]) == [ + "id", + "temperature", + "max_tokens", + "enable_thinking", + "thinking_budget", + ] + + +def test_agent_create_body_keeps_falsy_params(): + """False / 0.0 mean "set to this", not "unset".""" + from dashscope.agentstudio.types import ModelConfig + from dashscope.agentstudio.types.params import AgentCreateParams + + body = AgentCreateParams( + name="a", + model=ModelConfig( + id="qwen3-max", + temperature=0.0, + enable_thinking=False, + ), + ).to_dict() + assert body["model"] == { + "id": "qwen3-max", + "temperature": 0.0, + "enable_thinking": False, + } + + +def test_agent_create_rejects_a_raw_dict_model(): + """Request side takes the typed config; the response parser still maps.""" + from dashscope.agentstudio.types.params import ( + AgentCreateParams, + AgentUpdateParams, + ) + + for params_cls, kwargs in ( + (AgentCreateParams, {"name": "a"}), + (AgentUpdateParams, {"version": 2}), + ): + with pytest.raises(TypeError, match=r"ModelConfig\(\*\*raw\)"): + params_cls(model={"id": "qwen3-max", "temperature": 0.7}, **kwargs) + with pytest.raises(TypeError, match="model must be a ModelConfig"): + params_cls(model=42, **kwargs) + + +def test_agent_create_body_omits_unset_model_params(): + """Only the fields you set are sent; the rest keep server defaults.""" + from dashscope.agentstudio.types import ModelConfig + from dashscope.agentstudio.types.params import AgentCreateParams + + body = AgentCreateParams( + name="a", + model=ModelConfig(id="qwen3-max", max_tokens=1024), + ).to_dict() + assert body["model"] == {"id": "qwen3-max", "max_tokens": 1024} + for key in ("name", "temperature", "enable_thinking", "thinking_budget"): + assert key not in body["model"] + + +def test_model_config_carries_the_whole_surface_no_flat_kwargs(): + """Guard: inference params live on ModelConfig, never as flat kwargs.""" + import inspect + + from dashscope.agentstudio.resources.agents import Agents, AsyncAgents + from dashscope.agentstudio.types import ModelConfig + from dashscope.agentstudio.types.params import ( + AgentCreateParams, + AgentUpdateParams, + ) + + # "name" is the *agent's* name at this level, not the model's + scattered = set(ModelConfig._fields) - {"id", "name"} + assert scattered, "ModelConfig has no inference fields to guard" + + for entry in ( + AgentCreateParams.__init__, + AgentUpdateParams.__init__, + Agents.create, + Agents.update, + AsyncAgents.create, + AsyncAgents.update, + ): + kwargs = set(inspect.signature(entry).parameters) + assert "model" in kwargs, entry + assert kwargs.isdisjoint(scattered), entry + + +def test_agent_create_requires_a_model_with_id(): + """Missing or id-less configs fail fast.""" + from dashscope.agentstudio.types import ModelConfig + from dashscope.agentstudio.types.params import AgentCreateParams + + with pytest.raises(ValueError, match="model is required"): + AgentCreateParams(name="a", model=None) + for partial in (ModelConfig(), ModelConfig(temperature=0.5), ""): + with pytest.raises(ValueError, match="model.id is required"): + AgentCreateParams(name="a", model=partial) + + +def test_agent_update_body_omits_model_when_unset(): + """Update without model omits the field, so the remote config survives.""" + from dashscope.agentstudio.types.params import AgentUpdateParams + + body = AgentUpdateParams(version=2, name="a").to_dict() + assert "model" not in body + # explicit None means the same thing + assert "model" not in AgentUpdateParams(version=2, model=None).to_dict() + + +def test_agent_update_replaces_model_config_without_local_merge(): + """No local merge: the SDK sends exactly the config it was given.""" + from dashscope.agentstudio.types import ModelConfig + from dashscope.agentstudio.types.params import AgentUpdateParams + + body = AgentUpdateParams( + version=2, + model=ModelConfig(id="qwen3-max"), + ).to_dict() + assert body["model"] == {"id": "qwen3-max"} + for key in ( + "temperature", + "max_tokens", + "enable_thinking", + "thinking_budget", + ): + assert key not in body["model"] + for key in ("tools", "mcp_servers", "skills", "multiagent", "metadata"): + assert key not in body + + +def test_agent_update_partial_config_is_not_silently_dropped(): + """An explicit model argument must carry an id, even when empty.""" + from dashscope.agentstudio.types import ModelConfig + from dashscope.agentstudio.types.params import AgentUpdateParams + + with pytest.raises(ValueError, match="model.id is required"): + AgentUpdateParams(version=2, model=ModelConfig()) + with pytest.raises(ValueError, match="model.id is required"): + AgentUpdateParams(version=2, model=ModelConfig(temperature=0.5)) + + +def test_agents_create_sends_model_config(): + """The resource method forwards the config object into the request.""" + from dashscope.agentstudio.types import ModelConfig + + client = _client_with_recording_transport() + client.agents.create( + name="a", + model=ModelConfig( + id="qwen3-max", + temperature=0.7, + thinking_budget=2048, + ), + ) + call = client.transport.calls[0] + assert call["method"] == "POST" + assert call["path"] == "/agents" + assert call["json"]["model"] == { + "id": "qwen3-max", + "temperature": 0.7, + "thinking_budget": 2048, + } + + +def test_agents_update_sends_model_config(): + from dashscope.agentstudio.types import Agent + + client = _client_with_recording_transport() + agent = Agent(id="agent_1", model={"id": "qwen3-max", "max_tokens": 4096}) + client.agents.update( + "agent_1", + version=3, + model=agent.model.copy_with(temperature=0.5, max_tokens=8192), + ) + body = client.transport.calls[0]["json"] + assert body["version"] == 3 + assert body["model"] == { + "id": "qwen3-max", + "temperature": 0.5, + "max_tokens": 8192, + } + + +class _AsyncRecordingTransport: + """Async mirror of :class:`_RecordingTransport` for AsyncAgents.""" + + def __init__(self): + self.calls = [] + + async 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 _async_client_with_recording_transport(): + from dashscope.agentstudio import AsyncClient + + c = AsyncClient(api_key="test-key", base_url="http://test") + c.transport = _AsyncRecordingTransport() + return c + + +def test_agents_sync_and_async_mirror_each_other(): + """AsyncAgents must mirror Agents: same kwargs, same docstrings.""" + import inspect + + from dashscope.agentstudio.resources.agents import Agents, AsyncAgents + + for method in ( + "create", + "retrieve", + "update", + "archive", + "list", + "list_versions", + ): + sync = getattr(Agents, method) + asynchronous = getattr(AsyncAgents, method) + sync_params = inspect.signature(sync).parameters + async_params = inspect.signature(asynchronous).parameters + assert list(sync_params) == list(async_params), method + for name in sync_params: + assert ( + sync_params[name].default == async_params[name].default + ), f"{method}.{name}" + assert sync.__doc__ == asynchronous.__doc__, method + + +@pytest.mark.asyncio +async def test_async_agents_create_sends_model_config(): + from dashscope.agentstudio.types import ModelConfig + + client = _async_client_with_recording_transport() + await client.agents.create( + name="a", + model=ModelConfig( + id="qwen3-max", + enable_thinking=True, + thinking_budget=4096, + ), + ) + call = client.transport.calls[0] + assert call["path"] == "/agents" + assert call["json"]["model"] == { + "id": "qwen3-max", + "enable_thinking": True, + "thinking_budget": 4096, + } + + +@pytest.mark.asyncio +async def test_async_agents_update_sends_model_config(): + from dashscope.agentstudio.types import ModelConfig + + client = _async_client_with_recording_transport() + await client.agents.update( + "agent_1", + version=3, + model=ModelConfig(id="qwen3-max", temperature=0.3), + ) + assert client.transport.calls[0]["json"]["model"] == { + "id": "qwen3-max", + "temperature": 0.3, + } + + def test_agent_model_hydrates_multiagent(): """Agent response hydrates the multiagent dict into typed models.""" from dashscope.agentstudio.types import ( @@ -665,6 +973,77 @@ def close(self): assert list(ts.text_deltas) == ["事件增量", "流"] +# The shape the SDK reads: ``event_start`` announces a preview event (and with it +# which channel), ``event_delta`` carries one chunk. + + +def _start(eid, channel): + return {"type": "event_start", "event": {"id": eid, "type": channel}} + + +def _chunk(eid, text): + return { + "type": "event_delta", + "event_id": eid, + "delta": { + "type": "content_delta", + "index": 0, + "content": {"type": "text", "text": text}, + }, + } + + +_DELTA_FRAMES = [ + _start("sevt_m", "message"), + _chunk("sevt_m", "你好!"), + # A chunk whose event_start was never seen (a reconnect replays only + # persisted events) stays on the text side rather than vanishing. + _chunk("sevt_unseen", "补发"), +] + + +def _stream_of(frames): + from dashscope.agentstudio.resources.session_events import ( + _TypedEventStream, + ) + + return _TypedEventStream.from_raw_events(frames) + + +def test_second_pass_on_one_stream_raises_instead_of_returning_nothing(): + from dashscope.agentstudio.resources.session_events import ( + _AioTypedEventStream, + ) + + ts = _stream_of(_DELTA_FRAMES) + assert list(ts.text_deltas) == ["你好!", "补发"] + with pytest.raises(RuntimeError, match="single-pass"): + list(ts.text_deltas) + + class _FakeAio: + def __init__(self, items): + self._items = items + + async def __aiter__(self): + for item in self._items: + yield item + + ast = _AioTypedEventStream.__new__(_AioTypedEventStream) + ast._stream = _FakeAio(_DELTA_FRAMES) # pylint: disable=protected-access + assert _run(ast.text_deltas) == ["你好!", "补发"] + with pytest.raises(RuntimeError, match="single-pass"): + _run(ast.text_deltas) + + +def _run(agen): + import asyncio + + async def collect(): + return [c async for c in agen] + + return asyncio.run(collect()) + + # --------------------------------------------------------------------------- # Session resources & threads # ---------------------------------------------------------------------------